diff --git a/README.md b/README.md index cb4d947cd..c7fce9c68 100644 --- a/README.md +++ b/README.md @@ -59,18 +59,17 @@ NewPipe does not use any Google framework libraries, or the YouTube API. It only * Search/Watch Playlists * Watch as queues Playlists * Queuing videos +* Local playlists +* Subtitles +* Multi-service support (eg. SoundCloud in NewPipe Beta) ### Coming Features -* Multiservice support (eg. SoundCloud) -* Bookmarks -* Subtitles support -* livestream support +* Livestream support +* Cast to UPnP and Cast +* Show comments * ... and many more -### Multiservice support -Although NewPipe only supports YouTube at the moment, it's designed to support many more streaming services. The plan is, that NewPipe will get such support by the version 2.0. - ## Contribution Whether you have ideas, translations, design changes, code cleaning, or real heavy code changes, help is always welcome. The more is done the better it gets! diff --git a/app/build.gradle b/app/build.gradle index 86d6542e0..5c434c30c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -8,8 +8,8 @@ android { applicationId "org.schabi.newpipe" minSdkVersion 15 targetSdkVersion 27 - versionCode 47 - versionName "0.11.6" + versionCode 48 + versionName "0.12.0" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true @@ -48,14 +48,20 @@ android { } ext { - supportLibVersion = '27.0.2' + supportLibVersion = '27.1.0' + exoPlayerLibVersion = '2.7.1' + roomDbLibVersion = '1.0.0' + leakCanaryLibVersion = '1.5.4' + okHttpLibVersion = '1.5.0' + icepickLibVersion = '3.2.0' + stethoLibVersion = '1.5.0' } dependencies { androidTestImplementation('com.android.support.test.espresso:espresso-core:2.2.2') { exclude module: 'support-annotations' } - implementation 'com.github.TeamNewPipe:NewPipeExtractor:7fd21ec08581d' + implementation 'com.github.TeamNewPipe:NewPipeExtractor:f787b375e5fb6d' testImplementation 'junit:junit:4.12' testImplementation 'org.mockito:mockito-core:1.10.19' @@ -72,21 +78,29 @@ dependencies { implementation 'com.nostra13.universalimageloader:universal-image-loader:1.9.5' implementation 'de.hdodenhof:circleimageview:2.2.0' implementation 'com.github.nirhart:ParallaxScroll:dd53d1f9d1' - implementation 'com.nononsenseapps:filepicker:3.0.1' - implementation 'com.google.android.exoplayer:exoplayer:r2.5.4' + implementation 'com.nononsenseapps:filepicker:4.2.1' + implementation "com.google.android.exoplayer:exoplayer:$exoPlayerLibVersion" + implementation "com.google.android.exoplayer:extension-mediasession:$exoPlayerLibVersion" - debugImplementation 'com.facebook.stetho:stetho:1.5.0' - debugImplementation 'com.facebook.stetho:stetho-urlconnection:1.5.0' - debugImplementation 'com.android.support:multidex:1.0.2' + debugImplementation "com.facebook.stetho:stetho:$stethoLibVersion" + debugImplementation "com.facebook.stetho:stetho-urlconnection:$stethoLibVersion" + debugImplementation 'com.android.support:multidex:1.0.3' - implementation 'io.reactivex.rxjava2:rxjava:2.1.7' - implementation 'io.reactivex.rxjava2:rxandroid:2.0.1' - implementation 'com.jakewharton.rxbinding2:rxbinding:2.0.0' + implementation 'io.reactivex.rxjava2:rxjava:2.1.10' + implementation 'io.reactivex.rxjava2:rxandroid:2.0.2' + implementation 'com.jakewharton.rxbinding2:rxbinding:2.1.1' - implementation 'android.arch.persistence.room:runtime:1.0.0' - implementation 'android.arch.persistence.room:rxjava2:1.0.0' - annotationProcessor 'android.arch.persistence.room:compiler:1.0.0' + implementation "android.arch.persistence.room:runtime:$roomDbLibVersion" + implementation "android.arch.persistence.room:rxjava2:$roomDbLibVersion" + annotationProcessor "android.arch.persistence.room:compiler:$roomDbLibVersion" - implementation 'frankiesardo:icepick:3.2.0' - annotationProcessor 'frankiesardo:icepick-processor:3.2.0' + implementation "frankiesardo:icepick:$icepickLibVersion" + annotationProcessor "frankiesardo:icepick-processor:$icepickLibVersion" + + debugImplementation "com.squareup.leakcanary:leakcanary-android:$leakCanaryLibVersion" + betaImplementation "com.squareup.leakcanary:leakcanary-android-no-op:$leakCanaryLibVersion" + releaseImplementation "com.squareup.leakcanary:leakcanary-android-no-op:$leakCanaryLibVersion" + + implementation 'com.squareup.okhttp3:okhttp:3.9.1' + debugImplementation "com.facebook.stetho:stetho-okhttp3:$okHttpLibVersion" } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 1b2ac6835..fbe5ab23f 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -35,3 +35,10 @@ @icepick.* ; } -keepnames class * { @icepick.State *;} + +# Rules for OkHttp. Copy paste from https://github.com/square/okhttp +-dontwarn okhttp3.** +-dontwarn okio.** +-dontwarn javax.annotation.** +# A resource is loaded with a relative path so the package of this class must be preserved. +-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase diff --git a/app/src/debug/java/org/schabi/newpipe/DebugApp.java b/app/src/debug/java/org/schabi/newpipe/DebugApp.java index 1a507b4e5..aff354a69 100644 --- a/app/src/debug/java/org/schabi/newpipe/DebugApp.java +++ b/app/src/debug/java/org/schabi/newpipe/DebugApp.java @@ -1,9 +1,26 @@ package org.schabi.newpipe; import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.support.annotation.NonNull; import android.support.multidex.MultiDex; import com.facebook.stetho.Stetho; +import com.facebook.stetho.okhttp3.StethoInterceptor; +import com.squareup.leakcanary.AndroidHeapDumper; +import com.squareup.leakcanary.DefaultLeakDirectoryProvider; +import com.squareup.leakcanary.HeapDumper; +import com.squareup.leakcanary.LeakCanary; +import com.squareup.leakcanary.LeakDirectoryProvider; +import com.squareup.leakcanary.RefWatcher; + +import org.schabi.newpipe.extractor.Downloader; + +import java.io.File; +import java.util.concurrent.TimeUnit; + +import okhttp3.OkHttpClient; public class DebugApp extends App { private static final String TAG = DebugApp.class.toString(); @@ -17,10 +34,15 @@ public class DebugApp extends App { @Override public void onCreate() { super.onCreate(); - initStetho(); } + @Override + protected Downloader getDownloader() { + return org.schabi.newpipe.Downloader.init(new OkHttpClient.Builder() + .addNetworkInterceptor(new StethoInterceptor())); + } + private void initStetho() { // Create an InitializerBuilder Stetho.InitializerBuilder initializerBuilder = @@ -42,4 +64,41 @@ public class DebugApp extends App { // Initialize Stetho with the Initializer Stetho.initialize(initializer); } + + @Override + protected boolean isDisposedRxExceptionsReported() { + return PreferenceManager.getDefaultSharedPreferences(this) + .getBoolean(getString(R.string.allow_disposed_exceptions_key), false); + } + + @Override + protected RefWatcher installLeakCanary() { + return LeakCanary.refWatcher(this) + .heapDumper(new ToggleableHeapDumper(this)) + // give each object 10 seconds to be gc'ed, before leak canary gets nosy on it + .watchDelay(10, TimeUnit.SECONDS) + .buildAndInstall(); + } + + public static class ToggleableHeapDumper implements HeapDumper { + private final HeapDumper dumper; + private final SharedPreferences preferences; + private final String dumpingAllowanceKey; + + ToggleableHeapDumper(@NonNull final Context context) { + LeakDirectoryProvider leakDirectoryProvider = new DefaultLeakDirectoryProvider(context); + this.dumper = new AndroidHeapDumper(context, leakDirectoryProvider); + this.preferences = PreferenceManager.getDefaultSharedPreferences(context); + this.dumpingAllowanceKey = context.getString(R.string.allow_heap_dumping_key); + } + + private boolean isDumpingAllowed() { + return preferences.getBoolean(dumpingAllowanceKey, false); + } + + @Override + public File dumpHeap() { + return isDumpingAllowed() ? dumper.dumpHeap() : HeapDumper.RETRY_LATER; + } + } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f0a8d45e0..1edd67d24 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -28,6 +28,12 @@ + + + + + + + + + + android:resource="@xml/nnf_provider_paths"/> + + @@ -172,6 +185,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -180,68 +234,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java index 49f73853b..f436a26b8 100644 --- a/app/src/main/java/org/schabi/newpipe/App.java +++ b/app/src/main/java/org/schabi/newpipe/App.java @@ -1,23 +1,25 @@ package org.schabi.newpipe; -import android.app.AlarmManager; import android.app.Application; import android.app.NotificationChannel; import android.app.NotificationManager; -import android.app.PendingIntent; import android.content.Context; -import android.content.Intent; import android.os.Build; +import android.support.annotation.Nullable; import android.util.Log; +import com.nostra13.universalimageloader.cache.memory.impl.LRULimitedMemoryCache; import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.ImageLoaderConfiguration; +import com.squareup.leakcanary.LeakCanary; +import com.squareup.leakcanary.RefWatcher; import org.acra.ACRA; import org.acra.config.ACRAConfiguration; import org.acra.config.ACRAConfigurationException; import org.acra.config.ConfigurationBuilder; import org.acra.sender.ReportSenderFactory; +import org.schabi.newpipe.extractor.Downloader; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.report.AcraReportSenderFactory; import org.schabi.newpipe.report.ErrorActivity; @@ -29,9 +31,13 @@ import org.schabi.newpipe.util.StateSaver; import java.io.IOException; import java.io.InterruptedIOException; import java.net.SocketException; +import java.util.Collections; +import java.util.List; import io.reactivex.annotations.NonNull; import io.reactivex.exceptions.CompositeException; +import io.reactivex.exceptions.MissingBackpressureException; +import io.reactivex.exceptions.OnErrorNotImplementedException; import io.reactivex.exceptions.UndeliverableException; import io.reactivex.functions.Consumer; import io.reactivex.plugins.RxJavaPlugins; @@ -56,6 +62,7 @@ import io.reactivex.plugins.RxJavaPlugins; public class App extends Application { protected static final String TAG = App.class.toString(); + private RefWatcher refWatcher; @SuppressWarnings("unchecked") private static final Class[] reportSenderFactoryClasses = new Class[]{AcraReportSenderFactory.class}; @@ -71,54 +78,99 @@ public class App extends Application { public void onCreate() { super.onCreate(); + if (LeakCanary.isInAnalyzerProcess(this)) { + // This process is dedicated to LeakCanary for heap analysis. + // You should not init your app in this process. + return; + } + refWatcher = installLeakCanary(); + // Initialize settings first because others inits can use its values SettingsActivity.initSettings(this); - NewPipe.init(Downloader.getInstance()); - NewPipeDatabase.init(this); + NewPipe.init(getDownloader()); StateSaver.init(this); initNotificationChannel(); // Initialize image loader - ImageLoaderConfiguration config = new ImageLoaderConfiguration.Builder(this).build(); - ImageLoader.getInstance().init(config); + ImageLoader.getInstance().init(getImageLoaderConfigurations(10, 50)); configureRxJavaErrorHandler(); } + protected Downloader getDownloader() { + return org.schabi.newpipe.Downloader.init(null); + } + private void configureRxJavaErrorHandler() { // https://github.com/ReactiveX/RxJava/wiki/What's-different-in-2.0#error-handling RxJavaPlugins.setErrorHandler(new Consumer() { @Override public void accept(@NonNull Throwable throwable) throws Exception { - Log.e(TAG, "RxJavaPlugins.ErrorHandler called with -> : throwable = [" + throwable.getClass().getName() + "]"); + Log.e(TAG, "RxJavaPlugins.ErrorHandler called with -> : " + + "throwable = [" + throwable.getClass().getName() + "]"); if (throwable instanceof UndeliverableException) { // As UndeliverableException is a wrapper, get the cause of it to get the "real" exception throwable = throwable.getCause(); } + final List errors; if (throwable instanceof CompositeException) { - for (Throwable element : ((CompositeException) throwable).getExceptions()) { - if (checkThrowable(element)) return; + errors = ((CompositeException) throwable).getExceptions(); + } else { + errors = Collections.singletonList(throwable); + } + + for (final Throwable error : errors) { + if (isThrowableIgnored(error)) return; + if (isThrowableCritical(error)) { + reportException(error); + return; } } - if (checkThrowable(throwable)) return; + // Out-of-lifecycle exceptions should only be reported if a debug user wishes so, + // When exception is not reported, log it + if (isDisposedRxExceptionsReported()) { + reportException(throwable); + } else { + Log.e(TAG, "RxJavaPlugin: Undeliverable Exception received: ", throwable); + } + } + private boolean isThrowableIgnored(@NonNull final Throwable throwable) { + // Don't crash the application over a simple network problem + return ExtractorHelper.hasAssignableCauseThrowable(throwable, + IOException.class, SocketException.class, // network api cancellation + InterruptedException.class, InterruptedIOException.class); // blocking code disposed + } + + private boolean isThrowableCritical(@NonNull final Throwable throwable) { + // Though these exceptions cannot be ignored + return ExtractorHelper.hasAssignableCauseThrowable(throwable, + NullPointerException.class, IllegalArgumentException.class, // bug in app + OnErrorNotImplementedException.class, MissingBackpressureException.class, + IllegalStateException.class); // bug in operator + } + + private void reportException(@NonNull final Throwable throwable) { // Throw uncaught exception that will trigger the report system Thread.currentThread().getUncaughtExceptionHandler() .uncaughtException(Thread.currentThread(), throwable); } - - private boolean checkThrowable(@NonNull Throwable throwable) { - // Don't crash the application over a simple network problem - return ExtractorHelper.hasAssignableCauseThrowable(throwable, - IOException.class, SocketException.class, InterruptedException.class, InterruptedIOException.class); - } }); } + private ImageLoaderConfiguration getImageLoaderConfigurations(final int memoryCacheSizeMb, + final int diskCacheSizeMb) { + return new ImageLoaderConfiguration.Builder(this) + .memoryCache(new LRULimitedMemoryCache(memoryCacheSizeMb * 1024 * 1024)) + .diskCacheSize(diskCacheSizeMb * 1024 * 1024) + .imageDownloader(new ImageDownloader(getApplicationContext())) + .build(); + } + private void initACRA() { try { final ACRAConfiguration acraConfig = new ConfigurationBuilder(this) @@ -152,4 +204,17 @@ public class App extends Application { mNotificationManager.createNotificationChannel(mChannel); } + @Nullable + public static RefWatcher getRefWatcher(Context context) { + final App application = (App) context.getApplicationContext(); + return application.refWatcher; + } + + protected RefWatcher installLeakCanary() { + return RefWatcher.DISABLED; + } + + protected boolean isDisposedRxExceptionsReported() { + return false; + } } diff --git a/app/src/main/java/org/schabi/newpipe/BaseFragment.java b/app/src/main/java/org/schabi/newpipe/BaseFragment.java index 6cd79e2c9..ce4318427 100644 --- a/app/src/main/java/org/schabi/newpipe/BaseFragment.java +++ b/app/src/main/java/org/schabi/newpipe/BaseFragment.java @@ -1,18 +1,15 @@ package org.schabi.newpipe; import android.content.Context; -import android.content.res.TypedArray; import android.os.Bundle; -import android.support.annotation.AttrRes; import android.support.annotation.NonNull; import android.support.v4.app.Fragment; import android.support.v7.app.AppCompatActivity; import android.util.Log; import android.view.View; -import com.nostra13.universalimageloader.core.DisplayImageOptions; import com.nostra13.universalimageloader.core.ImageLoader; -import com.nostra13.universalimageloader.core.display.FadeInBitmapDisplayer; +import com.squareup.leakcanary.RefWatcher; import icepick.Icepick; @@ -67,6 +64,14 @@ public abstract class BaseFragment extends Fragment { protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { } + @Override + public void onDestroy() { + super.onDestroy(); + + RefWatcher refWatcher = App.getRefWatcher(getActivity()); + if (refWatcher != null) refWatcher.watch(this); + } + /*////////////////////////////////////////////////////////////////////////// // Init //////////////////////////////////////////////////////////////////////////*/ @@ -78,33 +83,13 @@ public abstract class BaseFragment extends Fragment { } /*////////////////////////////////////////////////////////////////////////// - // DisplayImageOptions default configurations + // Utils //////////////////////////////////////////////////////////////////////////*/ - public static final DisplayImageOptions BASE_OPTIONS = - new DisplayImageOptions.Builder().cacheInMemory(true).build(); - - public static final DisplayImageOptions DISPLAY_AVATAR_OPTIONS = - new DisplayImageOptions.Builder() - .cloneFrom(BASE_OPTIONS) - .showImageOnLoading(R.drawable.buddy) - .showImageForEmptyUri(R.drawable.buddy) - .showImageOnFail(R.drawable.buddy) - .build(); - - public static final DisplayImageOptions DISPLAY_THUMBNAIL_OPTIONS = - new DisplayImageOptions.Builder() - .cloneFrom(BASE_OPTIONS) - .displayer(new FadeInBitmapDisplayer(250)) - .showImageForEmptyUri(R.drawable.dummy_thumbnail) - .showImageOnFail(R.drawable.dummy_thumbnail) - .build(); - - public static final DisplayImageOptions DISPLAY_BANNER_OPTIONS = - new DisplayImageOptions.Builder() - .cloneFrom(BASE_OPTIONS) - .showImageOnLoading(R.drawable.channel_banner) - .showImageForEmptyUri(R.drawable.channel_banner) - .showImageOnFail(R.drawable.channel_banner) - .build(); + public void setTitle(String title) { + if (DEBUG) Log.d(TAG, "setTitle() called with: title = [" + title + "]"); + if (activity != null && activity.getSupportActionBar() != null) { + activity.getSupportActionBar().setTitle(title); + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/Downloader.java b/app/src/main/java/org/schabi/newpipe/Downloader.java index 77f12fa46..d9537c6b6 100644 --- a/app/src/main/java/org/schabi/newpipe/Downloader.java +++ b/app/src/main/java/org/schabi/newpipe/Downloader.java @@ -1,20 +1,21 @@ package org.schabi.newpipe; -import android.util.Log; +import android.support.annotation.Nullable; +import android.text.TextUtils; import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; -import org.schabi.newpipe.util.ExtractorHelper; -import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStreamReader; -import java.io.InterruptedIOException; -import java.net.URL; +import java.io.InputStream; +import java.util.Collections; import java.util.HashMap; -import java.util.Iterator; import java.util.Map; +import java.util.concurrent.TimeUnit; -import javax.net.ssl.HttpsURLConnection; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; /* @@ -38,32 +39,38 @@ import javax.net.ssl.HttpsURLConnection; */ public class Downloader implements org.schabi.newpipe.extractor.Downloader { - public static final String USER_AGENT = "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:43.0) Gecko/20100101 Firefox/43.0"; - private static String mCookies = ""; - private static Downloader instance = null; + private static Downloader instance; + private String mCookies; + private OkHttpClient client; - private Downloader() { + private Downloader(OkHttpClient.Builder builder) { + this.client = builder + .readTimeout(30, TimeUnit.SECONDS) + //.cache(new Cache(new File(context.getExternalCacheDir(), "okhttp"), 16 * 1024 * 1024)) + .build(); + } + + /** + * It's recommended to call exactly once in the entire lifetime of the application. + * + * @param builder if null, default builder will be used + */ + public static Downloader init(@Nullable OkHttpClient.Builder builder) { + return instance = new Downloader(builder != null ? builder : new OkHttpClient.Builder()); } public static Downloader getInstance() { - if (instance == null) { - synchronized (Downloader.class) { - if (instance == null) { - instance = new Downloader(); - } - } - } return instance; } - public static synchronized void setCookies(String cookies) { - Downloader.mCookies = cookies; + public String getCookies() { + return mCookies; } - public static synchronized String getCookies() { - return Downloader.mCookies; + public void setCookies(String cookies) { + mCookies = cookies; } /** @@ -92,14 +99,44 @@ public class Downloader implements org.schabi.newpipe.extractor.Downloader { */ @Override public String download(String siteUrl, Map customProperties) throws IOException, ReCaptchaException { - URL url = new URL(siteUrl); - HttpsURLConnection con = (HttpsURLConnection) url.openConnection(); - Iterator it = customProperties.entrySet().iterator(); - while (it.hasNext()) { - Map.Entry pair = (Map.Entry) it.next(); - con.setRequestProperty((String) pair.getKey(), (String) pair.getValue()); + return getBody(siteUrl, customProperties).string(); + } + + public InputStream stream(String siteUrl) throws IOException { + try { + return getBody(siteUrl, Collections.emptyMap()).byteStream(); + } catch (ReCaptchaException e) { + throw new IOException(e.getMessage(), e.getCause()); } - return dl(con); + } + + private ResponseBody getBody(String siteUrl, Map customProperties) throws IOException, ReCaptchaException { + final Request.Builder requestBuilder = new Request.Builder() + .method("GET", null).url(siteUrl) + .addHeader("User-Agent", USER_AGENT); + + for (Map.Entry header : customProperties.entrySet()) { + requestBuilder.addHeader(header.getKey(), header.getValue()); + } + + if (!TextUtils.isEmpty(mCookies)) { + requestBuilder.addHeader("Cookie", mCookies); + } + + final Request request = requestBuilder.build(); + final Response response = client.newCall(request).execute(); + final ResponseBody body = response.body(); + + if (response.code() == 429) { + throw new ReCaptchaException("reCaptcha Challenge requested"); + } + + if (body == null) { + response.close(); + return null; + } + + return body; } /** @@ -111,57 +148,6 @@ public class Downloader implements org.schabi.newpipe.extractor.Downloader { */ @Override public String download(String siteUrl) throws IOException, ReCaptchaException { - URL url = new URL(siteUrl); - HttpsURLConnection con = (HttpsURLConnection) url.openConnection(); - //HttpsURLConnection con = NetCipher.getHttpsURLConnection(url); - return dl(con); - } - - /** - * Common functionality between download(String url) and download(String url, String language) - */ - private static String dl(HttpsURLConnection con) throws IOException, ReCaptchaException { - StringBuilder response = new StringBuilder(); - BufferedReader in = null; - - try { - con.setReadTimeout(30 * 1000);// 30s - con.setRequestMethod("GET"); - con.setRequestProperty("User-Agent", USER_AGENT); - - if (getCookies().length() > 0) { - con.setRequestProperty("Cookie", getCookies()); - } - - in = new BufferedReader(new InputStreamReader(con.getInputStream())); - - String inputLine; - while ((inputLine = in.readLine()) != null) { - response.append(inputLine); - } - } catch (Exception e) { - Log.e("Downloader", "dl() ----- Exception thrown → " + e.getClass().getName()); - - if (ExtractorHelper.isInterruptedCaused(e)) { - throw new InterruptedIOException(e.getMessage()); - } - - /* - * HTTP 429 == Too Many Request - * Receive from Youtube.com = ReCaptcha challenge request - * See : https://github.com/rg3/youtube-dl/issues/5138 - */ - if (con.getResponseCode() == 429) { - throw new ReCaptchaException("reCaptcha Challenge requested"); - } - - throw new IOException(con.getResponseCode() + " " + con.getResponseMessage(), e); - } finally { - if (in != null) { - in.close(); - } - } - - return response.toString(); + return download(siteUrl, Collections.emptyMap()); } } diff --git a/app/src/main/java/org/schabi/newpipe/ImageDownloader.java b/app/src/main/java/org/schabi/newpipe/ImageDownloader.java new file mode 100644 index 000000000..eb5e92e88 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ImageDownloader.java @@ -0,0 +1,46 @@ +package org.schabi.newpipe; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.preference.PreferenceManager; + +import com.nostra13.universalimageloader.core.download.BaseImageDownloader; + +import org.schabi.newpipe.extractor.NewPipe; + +import java.io.IOException; +import java.io.InputStream; + +public class ImageDownloader extends BaseImageDownloader { + private final Resources resources; + private final SharedPreferences preferences; + private final String downloadThumbnailKey; + + public ImageDownloader(Context context) { + super(context); + this.resources = context.getResources(); + this.preferences = PreferenceManager.getDefaultSharedPreferences(context); + this.downloadThumbnailKey = context.getString(R.string.download_thumbnail_key); + } + + private boolean isDownloadingThumbnail() { + return preferences.getBoolean(downloadThumbnailKey, true); + } + + @SuppressLint("ResourceType") + @Override + public InputStream getStream(String imageUri, Object extra) throws IOException { + if (isDownloadingThumbnail()) { + return super.getStream(imageUri, extra); + } else { + return resources.openRawResource(R.drawable.dummy_thumbnail_dark); + } + } + + protected InputStream getStreamFromNetwork(String imageUri, Object extra) throws IOException { + final Downloader downloader = (Downloader) NewPipe.getDownloader(); + return downloader.stream(imageUri); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index 9e8f3fa76..1c62690c2 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -22,12 +22,11 @@ package org.schabi.newpipe; import android.content.Intent; import android.content.SharedPreferences; +import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.preference.PreferenceManager; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; import android.support.design.widget.NavigationView; import android.support.v4.app.Fragment; import android.support.v4.view.GravityCompat; @@ -37,46 +36,37 @@ import android.support.v7.app.ActionBarDrawerToggle; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.Toolbar; import android.util.Log; +import android.view.Gravity; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.TextView; +import android.widget.Toast; -import org.schabi.newpipe.database.AppDatabase; -import org.schabi.newpipe.database.history.dao.HistoryDAO; -import org.schabi.newpipe.database.history.dao.SearchHistoryDAO; -import org.schabi.newpipe.database.history.dao.WatchHistoryDAO; -import org.schabi.newpipe.database.history.model.HistoryEntry; -import org.schabi.newpipe.database.history.model.SearchHistoryEntry; -import org.schabi.newpipe.database.history.model.WatchHistoryEntry; +import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.stream.AudioStream; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.detail.VideoDetailFragment; import org.schabi.newpipe.fragments.list.search.SearchFragment; -import org.schabi.newpipe.history.HistoryListener; +import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.StateSaver; import org.schabi.newpipe.util.ThemeHelper; -import java.util.Date; - -import io.reactivex.disposables.Disposable; -import io.reactivex.functions.Consumer; -import io.reactivex.schedulers.Schedulers; -import io.reactivex.subjects.PublishSubject; - -public class MainActivity extends AppCompatActivity implements HistoryListener { +public class MainActivity extends AppCompatActivity { private static final String TAG = "MainActivity"; public static final boolean DEBUG = !BuildConfig.BUILD_TYPE.equals("release"); - private SharedPreferences sharedPreferences; private ActionBarDrawerToggle toggle = null; + private DrawerLayout drawer = null; + private NavigationView drawerItems = null; + private TextView headerServiceView = null; /*////////////////////////////////////////////////////////////////////////// // Activity's LifeCycle @@ -86,7 +76,6 @@ public class MainActivity extends AppCompatActivity implements HistoryListener { protected void onCreate(Bundle savedInstanceState) { if (DEBUG) Log.d(TAG, "onCreate() called with: savedInstanceState = [" + savedInstanceState + "]"); - sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this)); super.onCreate(savedInstanceState); @@ -98,13 +87,12 @@ public class MainActivity extends AppCompatActivity implements HistoryListener { setSupportActionBar(findViewById(R.id.toolbar)); setupDrawer(); - initHistory(); } private void setupDrawer() { final Toolbar toolbar = findViewById(R.id.toolbar); - final DrawerLayout drawer = findViewById(R.id.drawer_layout); - final NavigationView drawerItems = findViewById(R.id.navigation); + drawer = findViewById(R.id.drawer_layout); + drawerItems = findViewById(R.id.navigation); //drawerItems.setItemIconTintList(null); // Set null to use the original icon drawerItems.getMenu().getItem(ServiceHelper.getSelectedServiceId(this)).setChecked(true); @@ -129,34 +117,74 @@ public class MainActivity extends AppCompatActivity implements HistoryListener { } }); - drawerItems.setNavigationItemSelectedListener(item -> { - if (item.getGroupId() == R.id.menu_services_group) { - drawerItems.getMenu().getItem(ServiceHelper.getSelectedServiceId(this)).setChecked(false); - ServiceHelper.setSelectedServiceId(this, item.getTitle().toString()); - drawerItems.getMenu().getItem(ServiceHelper.getSelectedServiceId(this)).setChecked(true); - } - drawer.closeDrawers(); - return true; - }); + drawerItems.setNavigationItemSelectedListener(this::changeService); + + setupDrawerFooter(); + setupDrawerHeader(); } else { drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED); } } + private boolean changeService(MenuItem item) { + if (item.getGroupId() == R.id.menu_services_group) { + drawerItems.getMenu().getItem(ServiceHelper.getSelectedServiceId(this)).setChecked(false); + ServiceHelper.setSelectedServiceId(this, item.getTitle().toString()); + drawerItems.getMenu().getItem(ServiceHelper.getSelectedServiceId(this)).setChecked(true); + headerServiceView.setText("gurken"); + } else { + return false; + } + drawer.closeDrawers(); + return true; + } + + private void setupDrawerFooter() { + ImageButton settings = findViewById(R.id.drawer_settings); + ImageButton downloads = findViewById(R.id.drawer_downloads); + ImageButton history = findViewById(R.id.drawer_history); + + settings.setOnClickListener(view -> NavigationHelper.openSettings(this)); + downloads.setOnClickListener(view ->NavigationHelper.openDownloads(this)); + history.setOnClickListener(view -> NavigationHelper.openHistory(this)); + } + + private void setupDrawerHeader() { + headerServiceView = findViewById(R.id.drawer_header_service_view); + Button action = findViewById(R.id.drawer_header_action_button); + action.setOnClickListener(view -> { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(Uri.parse("https://newpipe.schabi.org/blog/")); + startActivity(intent); + drawer.closeDrawers(); + }); + } + @Override protected void onDestroy() { super.onDestroy(); if (!isChangingConfigurations()) { StateSaver.clearStateFiles(); } - - disposeHistory(); } @Override protected void onResume() { super.onResume(); + // close drawer on return, and don't show animation, so its looks like the drawer isn't open + // when the user returns to MainActivity + drawer.closeDrawer(Gravity.START, false); + try { + if(BuildConfig.BUILD_TYPE != "release" ) { + String selectedServiceName = NewPipe.getService( + ServiceHelper.getSelectedServiceId(this)).getServiceInfo().getName(); + headerServiceView.setText(selectedServiceName); + } + } catch (Exception e) { + ErrorActivity.reportUiError(this, e); + } + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); if (sharedPreferences.getBoolean(Constants.KEY_THEME_CHANGE, false)) { if (DEBUG) Log.d(TAG, "Theme has changed, recreating activity..."); @@ -357,75 +385,4 @@ public class MainActivity extends AppCompatActivity implements HistoryListener { NavigationHelper.gotoMainFragment(getSupportFragmentManager()); } } - - /*////////////////////////////////////////////////////////////////////////// - // History - //////////////////////////////////////////////////////////////////////////*/ - - private WatchHistoryDAO watchHistoryDAO; - private SearchHistoryDAO searchHistoryDAO; - private PublishSubject historyEntrySubject; - private Disposable disposable; - - private void initHistory() { - final AppDatabase database = NewPipeDatabase.getInstance(); - watchHistoryDAO = database.watchHistoryDAO(); - searchHistoryDAO = database.searchHistoryDAO(); - historyEntrySubject = PublishSubject.create(); - disposable = historyEntrySubject - .observeOn(Schedulers.io()) - .subscribe(getHistoryEntryConsumer()); - } - - private void disposeHistory() { - if (disposable != null) disposable.dispose(); - watchHistoryDAO = null; - searchHistoryDAO = null; - } - - @NonNull - private Consumer getHistoryEntryConsumer() { - return new Consumer() { - @Override - public void accept(HistoryEntry historyEntry) throws Exception { - //noinspection unchecked - HistoryDAO historyDAO = (HistoryDAO) - (historyEntry instanceof SearchHistoryEntry ? searchHistoryDAO : watchHistoryDAO); - - HistoryEntry latestEntry = historyDAO.getLatestEntry(); - if (historyEntry.hasEqualValues(latestEntry)) { - latestEntry.setCreationDate(historyEntry.getCreationDate()); - historyDAO.update(latestEntry); - } else { - historyDAO.insert(historyEntry); - } - } - }; - } - - private void addWatchHistoryEntry(StreamInfo streamInfo) { - if (sharedPreferences.getBoolean(getString(R.string.enable_watch_history_key), true)) { - WatchHistoryEntry entry = new WatchHistoryEntry(streamInfo); - historyEntrySubject.onNext(entry); - } - } - - @Override - public void onVideoPlayed(StreamInfo streamInfo, @Nullable VideoStream videoStream) { - addWatchHistoryEntry(streamInfo); - } - - @Override - public void onAudioPlayed(StreamInfo streamInfo, AudioStream audioStream) { - addWatchHistoryEntry(streamInfo); - } - - @Override - public void onSearch(int serviceId, String query) { - // Add search history entry - if (sharedPreferences.getBoolean(getString(R.string.enable_search_history_key), true)) { - SearchHistoryEntry searchHistoryEntry = new SearchHistoryEntry(new Date(), serviceId, query); - historyEntrySubject.onNext(searchHistoryEntry); - } - } } diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java index 7111abcf7..189e5aeab 100644 --- a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java +++ b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java @@ -7,25 +7,36 @@ import android.support.annotation.NonNull; import org.schabi.newpipe.database.AppDatabase; import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME; +import static org.schabi.newpipe.database.Migrations.MIGRATION_11_12; public final class NewPipeDatabase { - private static AppDatabase databaseInstance; + private static volatile AppDatabase databaseInstance; private NewPipeDatabase() { //no instance } - public static void init(Context context) { - databaseInstance = Room.databaseBuilder(context.getApplicationContext(), - AppDatabase.class, DATABASE_NAME - ).build(); + private static AppDatabase getDatabase(Context context) { + return Room + .databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME) + .addMigrations(MIGRATION_11_12) + .fallbackToDestructiveMigration() + .build(); } @NonNull - public static AppDatabase getInstance() { - if (databaseInstance == null) throw new RuntimeException("Database not initialized"); + public static AppDatabase getInstance(@NonNull Context context) { + AppDatabase result = databaseInstance; + if (result == null) { + synchronized (NewPipeDatabase.class) { + result = databaseInstance; + if (result == null) { + databaseInstance = (result = getDatabase(context)); + } + } + } - return databaseInstance; + return result; } } diff --git a/app/src/main/java/org/schabi/newpipe/ReCaptchaActivity.java b/app/src/main/java/org/schabi/newpipe/ReCaptchaActivity.java index d124bc6c4..a4e6730da 100644 --- a/app/src/main/java/org/schabi/newpipe/ReCaptchaActivity.java +++ b/app/src/main/java/org/schabi/newpipe/ReCaptchaActivity.java @@ -107,7 +107,7 @@ public class ReCaptchaActivity extends AppCompatActivity { // find cookies : s_gl & goojf and Add cookies to Downloader if (find_access_cookies(cookies)) { // Give cookies to Downloader class - Downloader.setCookies(mCookies); + Downloader.getInstance().setCookies(mCookies); // Closing activity and return to parent setResult(RESULT_OK); diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index 8aaa248dd..ad79c40b4 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -1,51 +1,78 @@ package org.schabi.newpipe; +import android.app.IntentService; +import android.content.DialogInterface; import android.content.Intent; +import android.content.SharedPreferences; import android.os.Bundle; +import android.preference.PreferenceManager; +import android.support.annotation.DrawableRes; +import android.support.annotation.Nullable; +import android.support.v4.app.NotificationCompat; +import android.support.v7.app.AlertDialog; import android.support.v7.app.AppCompatActivity; import android.text.TextUtils; +import android.view.ContextThemeWrapper; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.RadioButton; +import android.widget.RadioGroup; import android.widget.Toast; +import org.schabi.newpipe.extractor.Info; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.StreamingService.LinkType; +import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.playlist.PlaylistInfo; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.player.helper.PlayerHelper; +import org.schabi.newpipe.playlist.ChannelPlayQueue; +import org.schabi.newpipe.playlist.PlayQueue; +import org.schabi.newpipe.playlist.PlaylistPlayQueue; +import org.schabi.newpipe.playlist.SinglePlayQueue; import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.PermissionHelper; +import org.schabi.newpipe.util.ThemeHelper; +import java.io.Serializable; +import java.util.Arrays; import java.util.Collection; import java.util.HashSet; import icepick.Icepick; import icepick.State; import io.reactivex.Observable; +import io.reactivex.Single; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; +import io.reactivex.functions.Consumer; import io.reactivex.schedulers.Schedulers; -/* - * Copyright (C) Christian Schabesberger 2017 - * RouterActivity.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ +import static org.schabi.newpipe.util.ThemeHelper.resolveResourceIdFromAttr; /** - * This Acitivty is designed to route share/open intents to the specified service, and - * to the part of the service which can handle the url. + * Get the url from the intent and open it in the chosen preferred player */ public class RouterActivity extends AppCompatActivity { @State + protected int currentServiceId = -1; + private StreamingService currentService; + @State + protected LinkType currentLinkType; + @State + protected int selectedRadioPosition = -1; + protected int selectedPreviously = -1; + protected String currentUrl; protected CompositeDisposable disposables = new CompositeDisposable(); @@ -62,6 +89,10 @@ public class RouterActivity extends AppCompatActivity { finish(); } } + + setTheme(ThemeHelper.isLightThemeSelected(this) + ? R.style.RouterActivityThemeLight + : R.style.RouterActivityThemeDark); } @Override @@ -73,25 +104,43 @@ public class RouterActivity extends AppCompatActivity { @Override protected void onStart() { super.onStart(); + handleUrl(currentUrl); } - protected void handleUrl(String url) { - disposables.add(Observable - .fromCallable(() -> NavigationHelper.getIntentByLink(this, url)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(intent -> { - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); - startActivity(intent); + @Override + protected void onDestroy() { + super.onDestroy(); - finish(); - }, this::handleError) - ); + disposables.clear(); } - protected void handleError(Throwable error) { + private void handleUrl(String url) { + disposables.add(Observable + .fromCallable(() -> { + if (currentServiceId == -1) { + currentService = NewPipe.getServiceByUrl(url); + currentServiceId = currentService.getServiceId(); + currentLinkType = currentService.getLinkTypeByUrl(url); + currentUrl = NavigationHelper.getCleanUrl(currentService, url, currentLinkType); + } else { + currentService = NewPipe.getService(currentServiceId); + } + + return currentLinkType != LinkType.NONE; + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(result -> { + if (result) { + onSuccess(); + } else { + onError(); + } + }, this::handleError)); + } + + private void handleError(Throwable error) { error.printStackTrace(); if (error instanceof ExtractionException) { @@ -103,11 +152,345 @@ public class RouterActivity extends AppCompatActivity { finish(); } - @Override - protected void onDestroy() { - super.onDestroy(); + private void onError() { + Toast.makeText(this, R.string.url_not_supported_toast, Toast.LENGTH_LONG).show(); + finish(); + } - disposables.clear(); + protected void onSuccess() { + final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + boolean isExtVideoEnabled = preferences.getBoolean(getString(R.string.use_external_video_player_key), false); + boolean isExtAudioEnabled = preferences.getBoolean(getString(R.string.use_external_audio_player_key), false); + + if ((isExtAudioEnabled || isExtVideoEnabled) && currentLinkType != LinkType.STREAM) { + Toast.makeText(this, R.string.external_player_unsupported_link_type, Toast.LENGTH_LONG).show(); + finish(); + return; + } + + // TODO: Add some sort of "capabilities" field to services (audio only, video and audio, etc.) + if (currentService == ServiceList.SoundCloud) { + handleChoice(getString(R.string.background_player_key)); + return; + } + + final String playerChoiceKey = preferences.getString( + getString(R.string.preferred_open_action_key), + getString(R.string.preferred_open_action_default)); + final String alwaysAskKey = getString(R.string.always_ask_open_action_key); + + if (playerChoiceKey.equals(alwaysAskKey)) { + showDialog(); + } else { + handleChoice(playerChoiceKey); + } + } + + private void showDialog() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + final ContextThemeWrapper themeWrapper = new ContextThemeWrapper(this, + ThemeHelper.isLightThemeSelected(this) ? R.style.LightTheme : R.style.DarkTheme); + + LayoutInflater inflater = LayoutInflater.from(themeWrapper); + final LinearLayout rootLayout = (LinearLayout) inflater.inflate(R.layout.preferred_player_dialog_view, null, false); + final RadioGroup radioGroup = rootLayout.findViewById(android.R.id.list); + + final AdapterChoiceItem[] choices = { + new AdapterChoiceItem(getString(R.string.show_info_key), getString(R.string.show_info), + resolveResourceIdFromAttr(themeWrapper, R.attr.info)), + new AdapterChoiceItem(getString(R.string.video_player_key), getString(R.string.video_player), + resolveResourceIdFromAttr(themeWrapper, R.attr.play)), + new AdapterChoiceItem(getString(R.string.background_player_key), getString(R.string.background_player), + resolveResourceIdFromAttr(themeWrapper, R.attr.audio)), + new AdapterChoiceItem(getString(R.string.popup_player_key), getString(R.string.popup_player), + resolveResourceIdFromAttr(themeWrapper, R.attr.popup)) + }; + + final DialogInterface.OnClickListener dialogButtonsClickListener = (dialog, which) -> { + final int indexOfChild = radioGroup.indexOfChild( + radioGroup.findViewById(radioGroup.getCheckedRadioButtonId())); + final AdapterChoiceItem choice = choices[indexOfChild]; + + handleChoice(choice.key); + + if (which == DialogInterface.BUTTON_POSITIVE) { + preferences.edit().putString(getString(R.string.preferred_open_action_key), choice.key).apply(); + } + }; + + final AlertDialog alertDialog = new AlertDialog.Builder(themeWrapper) + .setTitle(R.string.preferred_player_share_menu_title) + .setView(radioGroup) + .setCancelable(true) + .setNegativeButton(R.string.just_once, dialogButtonsClickListener) + .setPositiveButton(R.string.always, dialogButtonsClickListener) + .setOnDismissListener((dialog) -> finish()) + .create(); + + alertDialog.setOnShowListener(dialog -> { + setDialogButtonsState(alertDialog, radioGroup.getCheckedRadioButtonId() != -1); + }); + + radioGroup.setOnCheckedChangeListener((group, checkedId) -> setDialogButtonsState(alertDialog, true)); + final View.OnClickListener radioButtonsClickListener = v -> { + final int indexOfChild = radioGroup.indexOfChild(v); + if (indexOfChild == -1) return; + + selectedPreviously = selectedRadioPosition; + selectedRadioPosition = indexOfChild; + + if (selectedPreviously == selectedRadioPosition) { + handleChoice(choices[selectedRadioPosition].key); + } + }; + + int id = 12345; + for (AdapterChoiceItem item : choices) { + final RadioButton radioButton = (RadioButton) inflater.inflate(R.layout.list_radio_icon_item, null); + radioButton.setText(item.description); + radioButton.setCompoundDrawablesWithIntrinsicBounds(item.icon, 0, 0, 0); + radioButton.setChecked(false); + radioButton.setId(id++); + radioButton.setLayoutParams(new RadioGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + radioButton.setOnClickListener(radioButtonsClickListener); + radioGroup.addView(radioButton); + } + + if (selectedRadioPosition == -1) { + final String lastSelectedPlayer = preferences.getString(getString(R.string.preferred_open_action_last_selected_key), null); + if (!TextUtils.isEmpty(lastSelectedPlayer)) { + for (int i = 0; i < choices.length; i++) { + AdapterChoiceItem c = choices[i]; + if (lastSelectedPlayer.equals(c.key)) { + selectedRadioPosition = i; + break; + } + } + } + } + + selectedRadioPosition = Math.min(Math.max(-1, selectedRadioPosition), choices.length - 1); + if (selectedRadioPosition != -1) { + ((RadioButton) radioGroup.getChildAt(selectedRadioPosition)).setChecked(true); + } + selectedPreviously = selectedRadioPosition; + + alertDialog.show(); + } + + private void setDialogButtonsState(AlertDialog dialog, boolean state) { + final Button negativeButton = dialog.getButton(DialogInterface.BUTTON_NEGATIVE); + final Button positiveButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE); + if (negativeButton == null || positiveButton == null) return; + + negativeButton.setEnabled(state); + positiveButton.setEnabled(state); + } + + private void handleChoice(final String playerChoiceKey) { + if (Arrays.asList(getResources() + .getStringArray(R.array.preferred_open_action_values_list)) + .contains(playerChoiceKey)) { + PreferenceManager.getDefaultSharedPreferences(this).edit() + .putString(getString(R.string.preferred_open_action_last_selected_key), + playerChoiceKey).apply(); + } + + if (playerChoiceKey.equals(getString(R.string.popup_player_key)) + && !PermissionHelper.isPopupEnabled(this)) { + PermissionHelper.showPopupEnablementToast(this); + finish(); + return; + } + + // stop and bypass FetcherService if InfoScreen was selected since + // StreamDetailFragment can fetch data itself + if(playerChoiceKey.equals(getString(R.string.show_info_key))) { + disposables.add(Observable + .fromCallable(() -> NavigationHelper.getIntentByLink(this, currentUrl)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(intent -> { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + + finish(); + }, this::handleError) + ); + return; + } + + final Intent intent = new Intent(this, FetcherService.class); + intent.putExtra(FetcherService.KEY_CHOICE, + new Choice(currentService.getServiceId(), + currentLinkType, + currentUrl, + playerChoiceKey)); + startService(intent); + + finish(); + } + + private static class AdapterChoiceItem { + final String description, key; + @DrawableRes + final int icon; + + AdapterChoiceItem(String key, String description, int icon) { + this.description = description; + this.key = key; + this.icon = icon; + } + } + + private static class Choice implements Serializable { + final int serviceId; + final String url, playerChoice; + final LinkType linkType; + + Choice(int serviceId, LinkType linkType, String url, String playerChoice) { + this.serviceId = serviceId; + this.linkType = linkType; + this.url = url; + this.playerChoice = playerChoice; + } + + @Override + public String toString() { + return serviceId + ":" + url + " > " + linkType + " ::: " + playerChoice; + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Service Fetcher + //////////////////////////////////////////////////////////////////////////*/ + + public static class FetcherService extends IntentService { + + private static final int ID = 456; + public static final String KEY_CHOICE = "key_choice"; + private Disposable fetcher; + + public FetcherService() { + super(FetcherService.class.getSimpleName()); + } + + @Override + public void onCreate() { + super.onCreate(); + startForeground(ID, createNotification().build()); + } + + @Override + protected void onHandleIntent(@Nullable Intent intent) { + if (intent == null) return; + + final Serializable serializable = intent.getSerializableExtra(KEY_CHOICE); + if (!(serializable instanceof Choice)) return; + Choice playerChoice = (Choice) serializable; + handleChoice(playerChoice); + } + + public void handleChoice(Choice choice) { + Single single = null; + UserAction userAction = UserAction.SOMETHING_ELSE; + + switch (choice.linkType) { + case STREAM: + single = ExtractorHelper.getStreamInfo(choice.serviceId, choice.url, false); + userAction = UserAction.REQUESTED_STREAM; + break; + case CHANNEL: + single = ExtractorHelper.getChannelInfo(choice.serviceId, choice.url, false); + userAction = UserAction.REQUESTED_CHANNEL; + break; + case PLAYLIST: + single = ExtractorHelper.getPlaylistInfo(choice.serviceId, choice.url, false); + userAction = UserAction.REQUESTED_PLAYLIST; + break; + } + + + if (single != null) { + final UserAction finalUserAction = userAction; + final Consumer resultHandler = getResultHandler(choice); + fetcher = single + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(info -> { + resultHandler.accept(info); + if (fetcher != null) fetcher.dispose(); + }, throwable -> ExtractorHelper.handleGeneralException(this, + choice.serviceId, choice.url, throwable, finalUserAction, ", opened with " + choice.playerChoice)); + } + } + + public Consumer getResultHandler(Choice choice) { + return info -> { + final String videoPlayerKey = getString(R.string.video_player_key); + final String backgroundPlayerKey = getString(R.string.background_player_key); + final String popupPlayerKey = getString(R.string.popup_player_key); + + final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + boolean isExtVideoEnabled = preferences.getBoolean(getString(R.string.use_external_video_player_key), false); + boolean isExtAudioEnabled = preferences.getBoolean(getString(R.string.use_external_audio_player_key), false); + boolean useOldVideoPlayer = PlayerHelper.isUsingOldPlayer(this); + + PlayQueue playQueue; + String playerChoice = choice.playerChoice; + + if (info instanceof StreamInfo) { + if (playerChoice.equals(backgroundPlayerKey) && isExtAudioEnabled) { + NavigationHelper.playOnExternalAudioPlayer(this, (StreamInfo) info); + + } else if (playerChoice.equals(videoPlayerKey) && isExtVideoEnabled) { + NavigationHelper.playOnExternalVideoPlayer(this, (StreamInfo) info); + + } else if (playerChoice.equals(videoPlayerKey) && useOldVideoPlayer) { + NavigationHelper.playOnOldVideoPlayer(this, (StreamInfo) info); + + } else { + playQueue = new SinglePlayQueue((StreamInfo) info); + + if (playerChoice.equals(videoPlayerKey)) { + NavigationHelper.playOnMainPlayer(this, playQueue); + } else if (playerChoice.equals(backgroundPlayerKey)) { + NavigationHelper.enqueueOnBackgroundPlayer(this, playQueue, true); + } else if (playerChoice.equals(popupPlayerKey)) { + NavigationHelper.enqueueOnPopupPlayer(this, playQueue, true); + } + } + } + + if (info instanceof ChannelInfo || info instanceof PlaylistInfo) { + playQueue = info instanceof ChannelInfo ? new ChannelPlayQueue((ChannelInfo) info) : new PlaylistPlayQueue((PlaylistInfo) info); + + if (playerChoice.equals(videoPlayerKey)) { + NavigationHelper.playOnMainPlayer(this, playQueue); + } else if (playerChoice.equals(backgroundPlayerKey)) { + NavigationHelper.playOnBackgroundPlayer(this, playQueue); + } else if (playerChoice.equals(popupPlayerKey)) { + NavigationHelper.playOnPopupPlayer(this, playQueue); + } + } + }; + } + + @Override + public void onDestroy() { + super.onDestroy(); + stopForeground(true); + if (fetcher != null) fetcher.dispose(); + } + + private NotificationCompat.Builder createNotification() { + return new NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) + .setOngoing(true) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentTitle(getString(R.string.preferred_player_fetcher_notification_title)) + .setContentText(getString(R.string.preferred_player_fetcher_notification_message)); + } } /*////////////////////////////////////////////////////////////////////////// @@ -119,9 +502,9 @@ public class RouterActivity extends AppCompatActivity { * brackets (\p{P}). See http://www.regular-expressions.info/unicode.html for * more details. */ - protected final static String REGEX_REMOVE_FROM_URL = "[\\p{Z}\\p{P}]"; + private final static String REGEX_REMOVE_FROM_URL = "[\\p{Z}\\p{P}]"; - protected String getUrl(Intent intent) { + private String getUrl(Intent intent) { // first gather data and find service String videoUrl = null; if (intent.getData() != null) { @@ -137,7 +520,7 @@ public class RouterActivity extends AppCompatActivity { return videoUrl; } - protected String removeHeadingGibberish(final String input) { + private String removeHeadingGibberish(final String input) { int start = 0; for (int i = input.indexOf("://") - 1; i >= 0; i--) { if (!input.substring(i, i + 1).matches("\\p{L}")) { @@ -148,7 +531,7 @@ public class RouterActivity extends AppCompatActivity { return input.substring(start, input.length()); } - protected String trim(final String input) { + private String trim(final String input) { if (input == null || input.length() < 1) { return input; } else { @@ -188,5 +571,4 @@ public class RouterActivity extends AppCompatActivity { } return result.toArray(new String[result.size()]); } - } diff --git a/app/src/main/java/org/schabi/newpipe/RouterPlayerActivity.java b/app/src/main/java/org/schabi/newpipe/RouterPlayerActivity.java deleted file mode 100644 index 7196e413d..000000000 --- a/app/src/main/java/org/schabi/newpipe/RouterPlayerActivity.java +++ /dev/null @@ -1,413 +0,0 @@ -package org.schabi.newpipe; - -import android.app.IntentService; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.SharedPreferences; -import android.os.Bundle; -import android.os.PersistableBundle; -import android.preference.PreferenceManager; -import android.support.annotation.DrawableRes; -import android.support.annotation.Nullable; -import android.support.v4.app.NotificationCompat; -import android.support.v7.app.AlertDialog; -import android.text.TextUtils; -import android.view.ContextThemeWrapper; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.LinearLayout; -import android.widget.RadioButton; -import android.widget.RadioGroup; -import android.widget.Toast; - -import org.schabi.newpipe.extractor.Info; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.ServiceList; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.StreamingService.LinkType; -import org.schabi.newpipe.extractor.channel.ChannelInfo; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.playlist.PlaylistInfo; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.player.helper.PlayerHelper; -import org.schabi.newpipe.playlist.ChannelPlayQueue; -import org.schabi.newpipe.playlist.PlayQueue; -import org.schabi.newpipe.playlist.PlaylistPlayQueue; -import org.schabi.newpipe.playlist.SinglePlayQueue; -import org.schabi.newpipe.report.UserAction; -import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.PermissionHelper; -import org.schabi.newpipe.util.ThemeHelper; - -import java.io.Serializable; -import java.util.Arrays; - -import icepick.State; -import io.reactivex.Observable; -import io.reactivex.Single; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.Disposable; -import io.reactivex.functions.Consumer; -import io.reactivex.schedulers.Schedulers; - -import static org.schabi.newpipe.util.ThemeHelper.resolveResourceIdFromAttr; - -/** - * Get the url from the intent and open it in the chosen preferred player - */ -public class RouterPlayerActivity extends RouterActivity { - - @State - protected int currentServiceId = -1; - private StreamingService currentService; - @State - protected LinkType currentLinkType; - @State - protected int selectedRadioPosition = -1; - protected int selectedPreviously = -1; - - @Override - public void onCreate(@Nullable Bundle savedInstanceState, @Nullable PersistableBundle persistentState) { - super.onCreate(savedInstanceState, persistentState); - setTheme(ThemeHelper.isLightThemeSelected(this) ? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark); - } - - @Override - protected void handleUrl(String url) { - disposables.add(Observable - .fromCallable(() -> { - if (currentServiceId == -1) { - currentService = NewPipe.getServiceByUrl(url); - currentServiceId = currentService.getServiceId(); - currentLinkType = currentService.getLinkTypeByUrl(url); - currentUrl = NavigationHelper.getCleanUrl(currentService, url, currentLinkType); - } else { - currentService = NewPipe.getService(currentServiceId); - } - - return currentLinkType != LinkType.NONE; - }) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - if (result) { - onSuccess(); - } else { - onError(); - } - }, this::handleError)); - } - - protected void onError() { - Toast.makeText(this, R.string.url_not_supported_toast, Toast.LENGTH_LONG).show(); - finish(); - } - - protected void onSuccess() { - final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); - boolean isExtVideoEnabled = preferences.getBoolean(getString(R.string.use_external_video_player_key), false); - boolean isExtAudioEnabled = preferences.getBoolean(getString(R.string.use_external_audio_player_key), false); - - if ((isExtAudioEnabled || isExtVideoEnabled) && currentLinkType != LinkType.STREAM) { - Toast.makeText(this, R.string.external_player_unsupported_link_type, Toast.LENGTH_LONG).show(); - finish(); - return; - } - - // TODO: Add some sort of "capabilities" field to services (audio only, video and audio, etc.) - if (currentService == ServiceList.SoundCloud.getService()) { - handleChoice(getString(R.string.background_player_key)); - return; - } - - final String playerChoiceKey = preferences.getString(getString(R.string.preferred_player_key), getString(R.string.preferred_player_default)); - final String alwaysAskKey = getString(R.string.always_ask_player_key); - - if (playerChoiceKey.equals(alwaysAskKey)) { - showDialog(); - } else { - handleChoice(playerChoiceKey); - } - } - - private void showDialog() { - SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); - final ContextThemeWrapper themeWrapper = new ContextThemeWrapper(this, - ThemeHelper.isLightThemeSelected(this) ? R.style.LightTheme : R.style.DarkTheme); - - LayoutInflater inflater = LayoutInflater.from(themeWrapper); - final LinearLayout rootLayout = (LinearLayout) inflater.inflate(R.layout.preferred_player_dialog_view, null, false); - final RadioGroup radioGroup = rootLayout.findViewById(android.R.id.list); - - final AdapterChoiceItem[] choices = { - new AdapterChoiceItem(getString(R.string.video_player_key), getString(R.string.video_player), - resolveResourceIdFromAttr(themeWrapper, R.attr.play)), - new AdapterChoiceItem(getString(R.string.background_player_key), getString(R.string.background_player), - resolveResourceIdFromAttr(themeWrapper, R.attr.audio)), - new AdapterChoiceItem(getString(R.string.popup_player_key), getString(R.string.popup_player), - resolveResourceIdFromAttr(themeWrapper, R.attr.popup)) - }; - - final DialogInterface.OnClickListener dialogButtonsClickListener = (dialog, which) -> { - final int indexOfChild = radioGroup.indexOfChild(radioGroup.findViewById(radioGroup.getCheckedRadioButtonId())); - final AdapterChoiceItem choice = choices[indexOfChild]; - - handleChoice(choice.key); - - if (which == DialogInterface.BUTTON_POSITIVE) { - preferences.edit().putString(getString(R.string.preferred_player_key), choice.key).apply(); - } - }; - - final AlertDialog alertDialog = new AlertDialog.Builder(themeWrapper) - .setTitle(R.string.preferred_player_share_menu_title) - .setView(radioGroup) - .setCancelable(true) - .setNegativeButton(R.string.just_once, dialogButtonsClickListener) - .setPositiveButton(R.string.always, dialogButtonsClickListener) - .setOnDismissListener((dialog) -> finish()) - .create(); - - alertDialog.setOnShowListener(dialog -> { - setDialogButtonsState(alertDialog, radioGroup.getCheckedRadioButtonId() != -1); - }); - - radioGroup.setOnCheckedChangeListener((group, checkedId) -> setDialogButtonsState(alertDialog, true)); - final View.OnClickListener radioButtonsClickListener = v -> { - final int indexOfChild = radioGroup.indexOfChild(v); - if (indexOfChild == -1) return; - - selectedPreviously = selectedRadioPosition; - selectedRadioPosition = indexOfChild; - - if (selectedPreviously == selectedRadioPosition) { - handleChoice(choices[selectedRadioPosition].key); - } - }; - - int id = 12345; - for (AdapterChoiceItem item : choices) { - final RadioButton radioButton = (RadioButton) inflater.inflate(R.layout.list_radio_icon_item, null); - radioButton.setText(item.description); - radioButton.setCompoundDrawablesWithIntrinsicBounds(item.icon, 0, 0, 0); - radioButton.setChecked(false); - radioButton.setId(id++); - radioButton.setLayoutParams(new RadioGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - radioButton.setOnClickListener(radioButtonsClickListener); - radioGroup.addView(radioButton); - } - - if (selectedRadioPosition == -1) { - final String lastSelectedPlayer = preferences.getString(getString(R.string.preferred_player_last_selected_key), null); - if (!TextUtils.isEmpty(lastSelectedPlayer)) { - for (int i = 0; i < choices.length; i++) { - AdapterChoiceItem c = choices[i]; - if (lastSelectedPlayer.equals(c.key)) { - selectedRadioPosition = i; - break; - } - } - } - } - - selectedRadioPosition = Math.min(Math.max(-1, selectedRadioPosition), choices.length - 1); - if (selectedRadioPosition != -1) { - ((RadioButton) radioGroup.getChildAt(selectedRadioPosition)).setChecked(true); - } - selectedPreviously = selectedRadioPosition; - - alertDialog.show(); - } - - private void setDialogButtonsState(AlertDialog dialog, boolean state) { - final Button negativeButton = dialog.getButton(DialogInterface.BUTTON_NEGATIVE); - final Button positiveButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE); - if (negativeButton == null || positiveButton == null) return; - - negativeButton.setEnabled(state); - positiveButton.setEnabled(state); - } - - private void handleChoice(final String playerChoiceKey) { - if (Arrays.asList(getResources().getStringArray(R.array.preferred_player_values_list)).contains(playerChoiceKey)) { - PreferenceManager.getDefaultSharedPreferences(this).edit() - .putString(getString(R.string.preferred_player_last_selected_key), playerChoiceKey).apply(); - } - - if (playerChoiceKey.equals(getString(R.string.popup_player_key)) && !PermissionHelper.isPopupEnabled(this)) { - PermissionHelper.showPopupEnablementToast(this); - finish(); - return; - } - - final Intent intent = new Intent(this, FetcherService.class); - intent.putExtra(FetcherService.KEY_CHOICE, new Choice(currentService.getServiceId(), currentLinkType, currentUrl, playerChoiceKey)); - startService(intent); - - finish(); - } - - private static class AdapterChoiceItem { - final String description, key; - @DrawableRes - final int icon; - - AdapterChoiceItem(String key, String description, int icon) { - this.description = description; - this.key = key; - this.icon = icon; - } - } - - private static class Choice implements Serializable { - final int serviceId; - final String url, playerChoice; - final LinkType linkType; - - Choice(int serviceId, LinkType linkType, String url, String playerChoice) { - this.serviceId = serviceId; - this.linkType = linkType; - this.url = url; - this.playerChoice = playerChoice; - } - - @Override - public String toString() { - return serviceId + ":" + url + " > " + linkType + " ::: " + playerChoice; - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Service Fetcher - //////////////////////////////////////////////////////////////////////////*/ - - public static class FetcherService extends IntentService { - - private static final int ID = 456; - public static final String KEY_CHOICE = "key_choice"; - private Disposable fetcher; - - public FetcherService() { - super(FetcherService.class.getSimpleName()); - } - - @Override - public void onCreate() { - super.onCreate(); - startForeground(ID, createNotification().build()); - } - - @Override - protected void onHandleIntent(@Nullable Intent intent) { - if (intent == null) return; - - final Serializable serializable = intent.getSerializableExtra(KEY_CHOICE); - if (!(serializable instanceof Choice)) return; - Choice playerChoice = (Choice) serializable; - handleChoice(playerChoice); - } - - public void handleChoice(Choice choice) { - Single single = null; - UserAction userAction = UserAction.SOMETHING_ELSE; - - switch (choice.linkType) { - case STREAM: - single = ExtractorHelper.getStreamInfo(choice.serviceId, choice.url, false); - userAction = UserAction.REQUESTED_STREAM; - break; - case CHANNEL: - single = ExtractorHelper.getChannelInfo(choice.serviceId, choice.url, false); - userAction = UserAction.REQUESTED_CHANNEL; - break; - case PLAYLIST: - single = ExtractorHelper.getPlaylistInfo(choice.serviceId, choice.url, false); - userAction = UserAction.REQUESTED_PLAYLIST; - break; - } - - - if (single != null) { - final UserAction finalUserAction = userAction; - final Consumer resultHandler = getResultHandler(choice); - fetcher = single - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(info -> { - resultHandler.accept(info); - if (fetcher != null) fetcher.dispose(); - }, throwable -> ExtractorHelper.handleGeneralException(this, - choice.serviceId, choice.url, throwable, finalUserAction, ", opened with " + choice.playerChoice)); - } - } - - public Consumer getResultHandler(Choice choice) { - return info -> { - final String videoPlayerKey = getString(R.string.video_player_key); - final String backgroundPlayerKey = getString(R.string.background_player_key); - final String popupPlayerKey = getString(R.string.popup_player_key); - - final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); - boolean isExtVideoEnabled = preferences.getBoolean(getString(R.string.use_external_video_player_key), false); - boolean isExtAudioEnabled = preferences.getBoolean(getString(R.string.use_external_audio_player_key), false); - boolean useOldVideoPlayer = PlayerHelper.isUsingOldPlayer(this); - - PlayQueue playQueue; - String playerChoice = choice.playerChoice; - - if (info instanceof StreamInfo) { - if (playerChoice.equals(backgroundPlayerKey) && isExtAudioEnabled) { - NavigationHelper.playOnExternalAudioPlayer(this, (StreamInfo) info); - - } else if (playerChoice.equals(videoPlayerKey) && isExtVideoEnabled) { - NavigationHelper.playOnExternalVideoPlayer(this, (StreamInfo) info); - - } else if (playerChoice.equals(videoPlayerKey) && useOldVideoPlayer) { - NavigationHelper.playOnOldVideoPlayer(this, (StreamInfo) info); - - } else { - playQueue = new SinglePlayQueue((StreamInfo) info); - - if (playerChoice.equals(videoPlayerKey)) { - NavigationHelper.playOnMainPlayer(this, playQueue); - } else if (playerChoice.equals(backgroundPlayerKey)) { - NavigationHelper.enqueueOnBackgroundPlayer(this, playQueue, true); - } else if (playerChoice.equals(popupPlayerKey)) { - NavigationHelper.enqueueOnPopupPlayer(this, playQueue, true); - } - } - } - - if (info instanceof ChannelInfo || info instanceof PlaylistInfo) { - playQueue = info instanceof ChannelInfo ? new ChannelPlayQueue((ChannelInfo) info) : new PlaylistPlayQueue((PlaylistInfo) info); - - if (playerChoice.equals(videoPlayerKey)) { - NavigationHelper.playOnMainPlayer(this, playQueue); - } else if (playerChoice.equals(backgroundPlayerKey)) { - NavigationHelper.playOnBackgroundPlayer(this, playQueue); - } else if (playerChoice.equals(popupPlayerKey)) { - NavigationHelper.playOnPopupPlayer(this, playQueue); - } - } - }; - } - - @Override - public void onDestroy() { - super.onDestroy(); - stopForeground(true); - if (fetcher != null) fetcher.dispose(); - } - - private NotificationCompat.Builder createNotification() { - return new NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) - .setOngoing(true) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setContentTitle(getString(R.string.preferred_player_fetcher_notification_title)) - .setContentText(getString(R.string.preferred_player_fetcher_notification_message)); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java index 21868e3c2..145a77c70 100644 --- a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java +++ b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java @@ -4,23 +4,52 @@ import android.arch.persistence.room.Database; import android.arch.persistence.room.RoomDatabase; import android.arch.persistence.room.TypeConverters; -import org.schabi.newpipe.database.history.Converters; import org.schabi.newpipe.database.history.dao.SearchHistoryDAO; -import org.schabi.newpipe.database.history.dao.WatchHistoryDAO; +import org.schabi.newpipe.database.history.dao.StreamHistoryDAO; import org.schabi.newpipe.database.history.model.SearchHistoryEntry; -import org.schabi.newpipe.database.history.model.WatchHistoryEntry; +import org.schabi.newpipe.database.history.model.StreamHistoryEntity; +import org.schabi.newpipe.database.playlist.dao.PlaylistDAO; +import org.schabi.newpipe.database.playlist.dao.PlaylistRemoteDAO; +import org.schabi.newpipe.database.playlist.dao.PlaylistStreamDAO; +import org.schabi.newpipe.database.playlist.model.PlaylistEntity; +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; +import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; +import org.schabi.newpipe.database.stream.dao.StreamDAO; +import org.schabi.newpipe.database.stream.dao.StreamStateDAO; +import org.schabi.newpipe.database.stream.model.StreamEntity; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.database.subscription.SubscriptionDAO; import org.schabi.newpipe.database.subscription.SubscriptionEntity; +import static org.schabi.newpipe.database.Migrations.DB_VER_12_0; + @TypeConverters({Converters.class}) -@Database(entities = {SubscriptionEntity.class, WatchHistoryEntry.class, SearchHistoryEntry.class}, version = 1, exportSchema = false) +@Database( + entities = { + SubscriptionEntity.class, SearchHistoryEntry.class, + StreamEntity.class, StreamHistoryEntity.class, StreamStateEntity.class, + PlaylistEntity.class, PlaylistStreamEntity.class, PlaylistRemoteEntity.class + }, + version = DB_VER_12_0, + exportSchema = false +) public abstract class AppDatabase extends RoomDatabase { public static final String DATABASE_NAME = "newpipe.db"; public abstract SubscriptionDAO subscriptionDAO(); - public abstract WatchHistoryDAO watchHistoryDAO(); - public abstract SearchHistoryDAO searchHistoryDAO(); + + public abstract StreamDAO streamDAO(); + + public abstract StreamHistoryDAO streamHistoryDAO(); + + public abstract StreamStateDAO streamStateDAO(); + + public abstract PlaylistDAO playlistDAO(); + + public abstract PlaylistStreamDAO playlistStreamDAO(); + + public abstract PlaylistRemoteDAO playlistRemoteDAO(); } diff --git a/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java b/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java index 03a94508b..425c122ca 100644 --- a/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java +++ b/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java @@ -23,9 +23,6 @@ public interface BasicDAO { @Insert(onConflict = OnConflictStrategy.FAIL) List insertAll(final Collection entities); - @Insert(onConflict = OnConflictStrategy.REPLACE) - long upsert(final Entity entity); - /* Searches */ Flowable> getAll(); diff --git a/app/src/main/java/org/schabi/newpipe/database/history/Converters.java b/app/src/main/java/org/schabi/newpipe/database/Converters.java similarity index 63% rename from app/src/main/java/org/schabi/newpipe/database/history/Converters.java rename to app/src/main/java/org/schabi/newpipe/database/Converters.java index 093c741f1..d48fbfaf1 100644 --- a/app/src/main/java/org/schabi/newpipe/database/history/Converters.java +++ b/app/src/main/java/org/schabi/newpipe/database/Converters.java @@ -1,7 +1,9 @@ -package org.schabi.newpipe.database.history; +package org.schabi.newpipe.database; import android.arch.persistence.room.TypeConverter; +import org.schabi.newpipe.extractor.stream.StreamType; + import java.util.Date; public class Converters { @@ -25,4 +27,14 @@ public class Converters { public static Long dateToTimestamp(Date date) { return date == null ? null : date.getTime(); } + + @TypeConverter + public static StreamType streamTypeOf(String value) { + return StreamType.valueOf(value); + } + + @TypeConverter + public static String stringOf(StreamType streamType) { + return streamType.name(); + } } diff --git a/app/src/main/java/org/schabi/newpipe/database/LocalItem.java b/app/src/main/java/org/schabi/newpipe/database/LocalItem.java new file mode 100644 index 000000000..e121739ab --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/LocalItem.java @@ -0,0 +1,13 @@ +package org.schabi.newpipe.database; + +public interface LocalItem { + enum LocalItemType { + PLAYLIST_LOCAL_ITEM, + PLAYLIST_REMOTE_ITEM, + + PLAYLIST_STREAM_ITEM, + STATISTIC_STREAM_ITEM, + } + + LocalItemType getLocalItemType(); +} diff --git a/app/src/main/java/org/schabi/newpipe/database/Migrations.java b/app/src/main/java/org/schabi/newpipe/database/Migrations.java new file mode 100644 index 000000000..239fc02bb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/Migrations.java @@ -0,0 +1,61 @@ +package org.schabi.newpipe.database; + +import android.arch.persistence.db.SupportSQLiteDatabase; +import android.arch.persistence.room.migration.Migration; +import android.support.annotation.NonNull; + +public class Migrations { + + public static final int DB_VER_11_0 = 1; + public static final int DB_VER_12_0 = 2; + + public static final Migration MIGRATION_11_12 = new Migration(DB_VER_11_0, DB_VER_12_0) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + /* + * Unfortunately these queries must be hardcoded due to the possibility of + * schema and names changing at a later date, thus invalidating the older migration + * scripts if they are not hardcoded. + * */ + + // Not much we can do about this, since room doesn't create tables before migration. + // It's either this or blasting the entire database anew. + database.execSQL("CREATE INDEX `index_search_history_search` ON `search_history` (`search`)"); + database.execSQL("CREATE TABLE IF NOT EXISTS `streams` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, `stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, `thumbnail_url` TEXT)"); + database.execSQL("CREATE UNIQUE INDEX `index_streams_service_id_url` ON `streams` (`service_id`, `url`)"); + database.execSQL("CREATE TABLE IF NOT EXISTS `stream_history` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )"); + database.execSQL("CREATE INDEX `index_stream_history_stream_id` ON `stream_history` (`stream_id`)"); + database.execSQL("CREATE TABLE IF NOT EXISTS `stream_state` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )"); + database.execSQL("CREATE TABLE IF NOT EXISTS `playlists` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT)"); + database.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)"); + database.execSQL("CREATE TABLE IF NOT EXISTS `playlist_stream_join` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"); + database.execSQL("CREATE UNIQUE INDEX `index_playlist_stream_join_playlist_id_join_index` ON `playlist_stream_join` (`playlist_id`, `join_index`)"); + database.execSQL("CREATE INDEX `index_playlist_stream_join_stream_id` ON `playlist_stream_join` (`stream_id`)"); + database.execSQL("CREATE TABLE IF NOT EXISTS `remote_playlists` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)"); + database.execSQL("CREATE INDEX `index_remote_playlists_name` ON `remote_playlists` (`name`)"); + database.execSQL("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` ON `remote_playlists` (`service_id`, `url`)"); + + // Populate streams table with existing entries in watch history + // Latest data first, thus ignoring older entries with the same indices + database.execSQL("INSERT OR IGNORE INTO streams (service_id, url, title, " + + "stream_type, duration, uploader, thumbnail_url) " + + + "SELECT service_id, url, title, 'VIDEO_STREAM', duration, " + + "uploader, thumbnail_url " + + + "FROM watch_history " + + "ORDER BY creation_date DESC"); + + // Once the streams have PKs, join them with the normalized history table + // and populate it with the remaining data from watch history + database.execSQL("INSERT INTO stream_history (stream_id, access_date, repeat_count)" + + "SELECT uid, creation_date, 1 " + + "FROM watch_history INNER JOIN streams " + + "ON watch_history.service_id == streams.service_id " + + "AND watch_history.url == streams.url " + + "ORDER BY creation_date DESC"); + + database.execSQL("DROP TABLE IF EXISTS watch_history"); + } + }; +} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.java b/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.java index 70799d971..b0a3c3a3c 100644 --- a/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.java +++ b/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.java @@ -2,7 +2,9 @@ package org.schabi.newpipe.database.history.dao; import android.arch.persistence.room.Dao; import android.arch.persistence.room.Query; +import android.support.annotation.Nullable; +import org.schabi.newpipe.database.BasicDAO; import org.schabi.newpipe.database.history.model.SearchHistoryEntry; import java.util.List; @@ -20,8 +22,9 @@ public interface SearchHistoryDAO extends HistoryDAO { String ORDER_BY_CREATION_DATE = " ORDER BY " + CREATION_DATE + " DESC"; - @Query("SELECT * FROM " + TABLE_NAME + " WHERE " + ID + " = (SELECT MAX(" + ID + ") FROM " + TABLE_NAME + ")") - @Override + @Query("SELECT * FROM " + TABLE_NAME + + " WHERE " + ID + " = (SELECT MAX(" + ID + ") FROM " + TABLE_NAME + ")") + @Nullable SearchHistoryEntry getLatestEntry(); @Query("DELETE FROM " + TABLE_NAME) diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java new file mode 100644 index 000000000..fd7a1b96f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java @@ -0,0 +1,68 @@ +package org.schabi.newpipe.database.history.dao; + + +import android.arch.persistence.room.Dao; +import android.arch.persistence.room.Query; +import android.support.annotation.Nullable; + +import org.schabi.newpipe.database.BasicDAO; +import org.schabi.newpipe.database.history.model.StreamHistoryEntry; +import org.schabi.newpipe.database.stream.StreamStatisticsEntry; +import org.schabi.newpipe.database.history.model.StreamHistoryEntity; + +import java.util.List; + +import io.reactivex.Flowable; + +import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_REPEAT_COUNT; +import static org.schabi.newpipe.database.stream.StreamStatisticsEntry.STREAM_LATEST_DATE; +import static org.schabi.newpipe.database.stream.StreamStatisticsEntry.STREAM_WATCH_COUNT; +import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID; +import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE; +import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.JOIN_STREAM_ID; +import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_ACCESS_DATE; +import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_HISTORY_TABLE; + +@Dao +public abstract class StreamHistoryDAO implements HistoryDAO { + @Query("SELECT * FROM " + STREAM_HISTORY_TABLE + + " WHERE " + STREAM_ACCESS_DATE + " = " + + "(SELECT MAX(" + STREAM_ACCESS_DATE + ") FROM " + STREAM_HISTORY_TABLE + ")") + @Override + @Nullable + public abstract StreamHistoryEntity getLatestEntry(); + + @Override + @Query("SELECT * FROM " + STREAM_HISTORY_TABLE) + public abstract Flowable> getAll(); + + @Override + @Query("DELETE FROM " + STREAM_HISTORY_TABLE) + public abstract int deleteAll(); + + @Override + public Flowable> listByService(int serviceId) { + throw new UnsupportedOperationException(); + } + + @Query("SELECT * FROM " + STREAM_TABLE + + " INNER JOIN " + STREAM_HISTORY_TABLE + + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID + + " ORDER BY " + STREAM_ACCESS_DATE + " DESC") + public abstract Flowable> getHistory(); + + @Query("DELETE FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") + public abstract int deleteStreamHistory(final long streamId); + + @Query("SELECT * FROM " + STREAM_TABLE + + + // Select the latest entry and watch count for each stream id on history table + " INNER JOIN " + + "(SELECT " + JOIN_STREAM_ID + ", " + + " MAX(" + STREAM_ACCESS_DATE + ") AS " + STREAM_LATEST_DATE + ", " + + " SUM(" + STREAM_REPEAT_COUNT + ") AS " + STREAM_WATCH_COUNT + + " FROM " + STREAM_HISTORY_TABLE + " GROUP BY " + JOIN_STREAM_ID + ")" + + + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID) + public abstract Flowable> getStatistics(); +} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/WatchHistoryDAO.java b/app/src/main/java/org/schabi/newpipe/database/history/dao/WatchHistoryDAO.java deleted file mode 100644 index a01d8e46d..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/history/dao/WatchHistoryDAO.java +++ /dev/null @@ -1,37 +0,0 @@ -package org.schabi.newpipe.database.history.dao; - -import android.arch.persistence.room.Dao; -import android.arch.persistence.room.Query; - -import org.schabi.newpipe.database.history.model.WatchHistoryEntry; - -import java.util.List; - -import io.reactivex.Flowable; - -import static org.schabi.newpipe.database.history.model.WatchHistoryEntry.CREATION_DATE; -import static org.schabi.newpipe.database.history.model.WatchHistoryEntry.ID; -import static org.schabi.newpipe.database.history.model.WatchHistoryEntry.SERVICE_ID; -import static org.schabi.newpipe.database.history.model.WatchHistoryEntry.TABLE_NAME; - -@Dao -public interface WatchHistoryDAO extends HistoryDAO { - - String ORDER_BY_CREATION_DATE = " ORDER BY " + CREATION_DATE + " DESC"; - - @Query("SELECT * FROM " + TABLE_NAME + " WHERE " + ID + " = (SELECT MAX(" + ID + ") FROM " + TABLE_NAME + ")") - @Override - WatchHistoryEntry getLatestEntry(); - - @Query("DELETE FROM " + TABLE_NAME) - @Override - int deleteAll(); - - @Query("SELECT * FROM " + TABLE_NAME + ORDER_BY_CREATION_DATE) - @Override - Flowable> getAll(); - - @Query("SELECT * FROM " + TABLE_NAME + " WHERE " + SERVICE_ID + " = :serviceId" + ORDER_BY_CREATION_DATE) - @Override - Flowable> listByService(int serviceId); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/HistoryEntry.java b/app/src/main/java/org/schabi/newpipe/database/history/model/HistoryEntry.java deleted file mode 100644 index cd9ac259e..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/history/model/HistoryEntry.java +++ /dev/null @@ -1,60 +0,0 @@ -package org.schabi.newpipe.database.history.model; - -import android.arch.persistence.room.ColumnInfo; -import android.arch.persistence.room.Entity; -import android.arch.persistence.room.Ignore; -import android.arch.persistence.room.PrimaryKey; - -import java.util.Date; - -@Entity -public abstract class HistoryEntry { - - public static final String ID = "id"; - public static final String SERVICE_ID = "service_id"; - public static final String CREATION_DATE = "creation_date"; - - @ColumnInfo(name = CREATION_DATE) - private Date creationDate; - - @ColumnInfo(name = SERVICE_ID) - private int serviceId; - - @ColumnInfo(name = ID) - @PrimaryKey(autoGenerate = true) - private long id; - - public HistoryEntry(Date creationDate, int serviceId) { - this.serviceId = serviceId; - this.creationDate = creationDate; - } - - public long getId() { - return id; - } - - public void setId(long id) { - this.id = id; - } - - public Date getCreationDate() { - return creationDate; - } - - public void setCreationDate(Date creationDate) { - this.creationDate = creationDate; - } - - public int getServiceId() { - return serviceId; - } - - public void setServiceId(int serviceId) { - this.serviceId = serviceId; - } - - @Ignore - public boolean hasEqualValues(HistoryEntry otherEntry) { - return otherEntry != null && getServiceId() == otherEntry.getServiceId(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.java b/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.java index d18974089..dcfff99b8 100644 --- a/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.java +++ b/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.java @@ -3,23 +3,66 @@ package org.schabi.newpipe.database.history.model; import android.arch.persistence.room.ColumnInfo; import android.arch.persistence.room.Entity; import android.arch.persistence.room.Ignore; +import android.arch.persistence.room.Index; +import android.arch.persistence.room.PrimaryKey; import java.util.Date; -@Entity(tableName = SearchHistoryEntry.TABLE_NAME) -public class SearchHistoryEntry extends HistoryEntry { +import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.SEARCH; +@Entity(tableName = SearchHistoryEntry.TABLE_NAME, + indices = {@Index(value = SEARCH)}) +public class SearchHistoryEntry { + + public static final String ID = "id"; public static final String TABLE_NAME = "search_history"; + public static final String SERVICE_ID = "service_id"; + public static final String CREATION_DATE = "creation_date"; public static final String SEARCH = "search"; + @ColumnInfo(name = ID) + @PrimaryKey(autoGenerate = true) + private long id; + + @ColumnInfo(name = CREATION_DATE) + private Date creationDate; + + @ColumnInfo(name = SERVICE_ID) + private int serviceId; + @ColumnInfo(name = SEARCH) private String search; public SearchHistoryEntry(Date creationDate, int serviceId, String search) { - super(creationDate, serviceId); + this.serviceId = serviceId; + this.creationDate = creationDate; this.search = search; } + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public Date getCreationDate() { + return creationDate; + } + + public void setCreationDate(Date creationDate) { + this.creationDate = creationDate; + } + + public int getServiceId() { + return serviceId; + } + + public void setServiceId(int serviceId) { + this.serviceId = serviceId; + } + public String getSearch() { return search; } @@ -29,9 +72,8 @@ public class SearchHistoryEntry extends HistoryEntry { } @Ignore - @Override - public boolean hasEqualValues(HistoryEntry otherEntry) { - return otherEntry instanceof SearchHistoryEntry && super.hasEqualValues(otherEntry) - && getSearch().equals(((SearchHistoryEntry) otherEntry).getSearch()); + public boolean hasEqualValues(SearchHistoryEntry otherEntry) { + return getServiceId() == otherEntry.getServiceId() && + getSearch().equals(otherEntry.getSearch()); } } diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.java b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.java new file mode 100644 index 000000000..b553f437d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.java @@ -0,0 +1,79 @@ +package org.schabi.newpipe.database.history.model; + +import android.arch.persistence.room.ColumnInfo; +import android.arch.persistence.room.Entity; +import android.arch.persistence.room.ForeignKey; +import android.arch.persistence.room.Ignore; +import android.arch.persistence.room.Index; +import android.support.annotation.NonNull; + +import org.schabi.newpipe.database.stream.model.StreamEntity; + +import java.util.Date; + +import static android.arch.persistence.room.ForeignKey.CASCADE; +import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_HISTORY_TABLE; +import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.JOIN_STREAM_ID; +import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_ACCESS_DATE; + +@Entity(tableName = STREAM_HISTORY_TABLE, + primaryKeys = {JOIN_STREAM_ID, STREAM_ACCESS_DATE}, + // No need to index for timestamp as they will almost always be unique + indices = {@Index(value = {JOIN_STREAM_ID})}, + foreignKeys = { + @ForeignKey(entity = StreamEntity.class, + parentColumns = StreamEntity.STREAM_ID, + childColumns = JOIN_STREAM_ID, + onDelete = CASCADE, onUpdate = CASCADE) + }) +public class StreamHistoryEntity { + final public static String STREAM_HISTORY_TABLE = "stream_history"; + final public static String JOIN_STREAM_ID = "stream_id"; + final public static String STREAM_ACCESS_DATE = "access_date"; + final public static String STREAM_REPEAT_COUNT = "repeat_count"; + + @ColumnInfo(name = JOIN_STREAM_ID) + private long streamUid; + + @NonNull + @ColumnInfo(name = STREAM_ACCESS_DATE) + private Date accessDate; + + @ColumnInfo(name = STREAM_REPEAT_COUNT) + private long repeatCount; + + public StreamHistoryEntity(long streamUid, @NonNull Date accessDate, long repeatCount) { + this.streamUid = streamUid; + this.accessDate = accessDate; + this.repeatCount = repeatCount; + } + + @Ignore + public StreamHistoryEntity(long streamUid, @NonNull Date accessDate) { + this(streamUid, accessDate, 1); + } + + public long getStreamUid() { + return streamUid; + } + + public void setStreamUid(long streamUid) { + this.streamUid = streamUid; + } + + public Date getAccessDate() { + return accessDate; + } + + public void setAccessDate(@NonNull Date accessDate) { + this.accessDate = accessDate; + } + + public long getRepeatCount() { + return repeatCount; + } + + public void setRepeatCount(long repeatCount) { + this.repeatCount = repeatCount; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.java b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.java new file mode 100644 index 000000000..772b96cc4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.java @@ -0,0 +1,59 @@ +package org.schabi.newpipe.database.history.model; + +import android.arch.persistence.room.ColumnInfo; + +import org.schabi.newpipe.database.stream.model.StreamEntity; +import org.schabi.newpipe.extractor.stream.StreamType; + +import java.util.Date; + +public class StreamHistoryEntry { + @ColumnInfo(name = StreamEntity.STREAM_ID) + final public long uid; + @ColumnInfo(name = StreamEntity.STREAM_SERVICE_ID) + final public int serviceId; + @ColumnInfo(name = StreamEntity.STREAM_URL) + final public String url; + @ColumnInfo(name = StreamEntity.STREAM_TITLE) + final public String title; + @ColumnInfo(name = StreamEntity.STREAM_TYPE) + final public StreamType streamType; + @ColumnInfo(name = StreamEntity.STREAM_DURATION) + final public long duration; + @ColumnInfo(name = StreamEntity.STREAM_UPLOADER) + final public String uploader; + @ColumnInfo(name = StreamEntity.STREAM_THUMBNAIL_URL) + final public String thumbnailUrl; + @ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID) + final public long streamId; + @ColumnInfo(name = StreamHistoryEntity.STREAM_ACCESS_DATE) + final public Date accessDate; + @ColumnInfo(name = StreamHistoryEntity.STREAM_REPEAT_COUNT) + final public long repeatCount; + + public StreamHistoryEntry(long uid, int serviceId, String url, String title, + StreamType streamType, long duration, String uploader, + String thumbnailUrl, long streamId, Date accessDate, + long repeatCount) { + this.uid = uid; + this.serviceId = serviceId; + this.url = url; + this.title = title; + this.streamType = streamType; + this.duration = duration; + this.uploader = uploader; + this.thumbnailUrl = thumbnailUrl; + this.streamId = streamId; + this.accessDate = accessDate; + this.repeatCount = repeatCount; + } + + public StreamHistoryEntity toStreamHistoryEntity() { + return new StreamHistoryEntity(streamId, accessDate, repeatCount); + } + + public boolean hasEqualValues(StreamHistoryEntry other) { + return this.uid == other.uid && streamId == other.streamId && + accessDate.compareTo(other.accessDate) == 0; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/WatchHistoryEntry.java b/app/src/main/java/org/schabi/newpipe/database/history/model/WatchHistoryEntry.java deleted file mode 100644 index bfd84d377..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/history/model/WatchHistoryEntry.java +++ /dev/null @@ -1,109 +0,0 @@ -package org.schabi.newpipe.database.history.model; - -import android.arch.persistence.room.ColumnInfo; -import android.arch.persistence.room.Entity; -import android.arch.persistence.room.Ignore; - -import org.schabi.newpipe.extractor.stream.StreamInfo; - -import java.util.Date; - -@Entity(tableName = WatchHistoryEntry.TABLE_NAME) -public class WatchHistoryEntry extends HistoryEntry { - - public static final String TABLE_NAME = "watch_history"; - public static final String TITLE = "title"; - public static final String URL = "url"; - public static final String STREAM_ID = "stream_id"; - public static final String THUMBNAIL_URL = "thumbnail_url"; - public static final String UPLOADER = "uploader"; - public static final String DURATION = "duration"; - - @ColumnInfo(name = TITLE) - private String title; - - @ColumnInfo(name = URL) - private String url; - - @ColumnInfo(name = STREAM_ID) - private String streamId; - - @ColumnInfo(name = THUMBNAIL_URL) - private String thumbnailURL; - - @ColumnInfo(name = UPLOADER) - private String uploader; - - @ColumnInfo(name = DURATION) - private long duration; - - public WatchHistoryEntry(Date creationDate, int serviceId, String title, String url, String streamId, String thumbnailURL, String uploader, long duration) { - super(creationDate, serviceId); - this.title = title; - this.url = url; - this.streamId = streamId; - this.thumbnailURL = thumbnailURL; - this.uploader = uploader; - this.duration = duration; - } - - public WatchHistoryEntry(StreamInfo streamInfo) { - this(new Date(), streamInfo.getServiceId(), streamInfo.getName(), streamInfo.getUrl(), - streamInfo.id, streamInfo.thumbnail_url, streamInfo.uploader_name, streamInfo.duration); - } - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } - - public String getTitle() { - return title; - } - - public void setTitle(String title) { - this.title = title; - } - - public String getStreamId() { - return streamId; - } - - public void setStreamId(String streamId) { - this.streamId = streamId; - } - - public String getThumbnailURL() { - return thumbnailURL; - } - - public void setThumbnailURL(String thumbnailURL) { - this.thumbnailURL = thumbnailURL; - } - - public String getUploader() { - return uploader; - } - - public void setUploader(String uploader) { - this.uploader = uploader; - } - - public long getDuration() { - return duration; - } - - public void setDuration(int duration) { - this.duration = duration; - } - - @Ignore - @Override - public boolean hasEqualValues(HistoryEntry otherEntry) { - return otherEntry instanceof WatchHistoryEntry && super.hasEqualValues(otherEntry) - && getUrl().equals(((WatchHistoryEntry) otherEntry).getUrl()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java new file mode 100644 index 000000000..fd99f84a1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java @@ -0,0 +1,7 @@ +package org.schabi.newpipe.database.playlist; + +import org.schabi.newpipe.database.LocalItem; + +public interface PlaylistLocalItem extends LocalItem { + String getOrderingName(); +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java new file mode 100644 index 000000000..6d9fc2213 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java @@ -0,0 +1,37 @@ +package org.schabi.newpipe.database.playlist; + +import android.arch.persistence.room.ColumnInfo; + +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID; +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME; +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL; + +public class PlaylistMetadataEntry implements PlaylistLocalItem { + final public static String PLAYLIST_STREAM_COUNT = "streamCount"; + + @ColumnInfo(name = PLAYLIST_ID) + final public long uid; + @ColumnInfo(name = PLAYLIST_NAME) + final public String name; + @ColumnInfo(name = PLAYLIST_THUMBNAIL_URL) + final public String thumbnailUrl; + @ColumnInfo(name = PLAYLIST_STREAM_COUNT) + final public long streamCount; + + public PlaylistMetadataEntry(long uid, String name, String thumbnailUrl, long streamCount) { + this.uid = uid; + this.name = name; + this.thumbnailUrl = thumbnailUrl; + this.streamCount = streamCount; + } + + @Override + public LocalItemType getLocalItemType() { + return LocalItemType.PLAYLIST_LOCAL_ITEM; + } + + @Override + public String getOrderingName() { + return name; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.java new file mode 100644 index 000000000..b6ecfe1f0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.java @@ -0,0 +1,60 @@ +package org.schabi.newpipe.database.playlist; + +import android.arch.persistence.room.ColumnInfo; + +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; +import org.schabi.newpipe.database.stream.model.StreamEntity; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.extractor.stream.StreamType; + +public class PlaylistStreamEntry implements LocalItem { + @ColumnInfo(name = StreamEntity.STREAM_ID) + final public long uid; + @ColumnInfo(name = StreamEntity.STREAM_SERVICE_ID) + final public int serviceId; + @ColumnInfo(name = StreamEntity.STREAM_URL) + final public String url; + @ColumnInfo(name = StreamEntity.STREAM_TITLE) + final public String title; + @ColumnInfo(name = StreamEntity.STREAM_TYPE) + final public StreamType streamType; + @ColumnInfo(name = StreamEntity.STREAM_DURATION) + final public long duration; + @ColumnInfo(name = StreamEntity.STREAM_UPLOADER) + final public String uploader; + @ColumnInfo(name = StreamEntity.STREAM_THUMBNAIL_URL) + final public String thumbnailUrl; + @ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID) + final public long streamId; + @ColumnInfo(name = PlaylistStreamEntity.JOIN_INDEX) + final public int joinIndex; + + public PlaylistStreamEntry(long uid, int serviceId, String url, String title, + StreamType streamType, long duration, String uploader, + String thumbnailUrl, long streamId, int joinIndex) { + this.uid = uid; + this.serviceId = serviceId; + this.url = url; + this.title = title; + this.streamType = streamType; + this.duration = duration; + this.uploader = uploader; + this.thumbnailUrl = thumbnailUrl; + this.streamId = streamId; + this.joinIndex = joinIndex; + } + + public StreamInfoItem toStreamInfoItem() throws IllegalArgumentException { + StreamInfoItem item = new StreamInfoItem(serviceId, url, title, streamType); + item.setThumbnailUrl(thumbnailUrl); + item.setUploaderName(uploader); + item.setDuration(duration); + return item; + } + + @Override + public LocalItemType getLocalItemType() { + return LocalItemType.PLAYLIST_STREAM_ITEM; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java new file mode 100644 index 000000000..88d5645af --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java @@ -0,0 +1,38 @@ +package org.schabi.newpipe.database.playlist.dao; + +import android.arch.persistence.room.Dao; +import android.arch.persistence.room.Query; +import android.arch.persistence.room.Transaction; + +import org.schabi.newpipe.database.BasicDAO; +import org.schabi.newpipe.database.playlist.model.PlaylistEntity; + +import java.util.List; + +import io.reactivex.Flowable; + +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID; +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME; +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE; + +@Dao +public abstract class PlaylistDAO implements BasicDAO { + @Override + @Query("SELECT * FROM " + PLAYLIST_TABLE) + public abstract Flowable> getAll(); + + @Override + @Query("DELETE FROM " + PLAYLIST_TABLE) + public abstract int deleteAll(); + + @Override + public Flowable> listByService(int serviceId) { + throw new UnsupportedOperationException(); + } + + @Query("SELECT * FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId") + public abstract Flowable> getPlaylist(final long playlistId); + + @Query("DELETE FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId") + public abstract int deletePlaylist(final long playlistId); +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java new file mode 100644 index 000000000..82d767b07 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java @@ -0,0 +1,60 @@ +package org.schabi.newpipe.database.playlist.dao; + +import android.arch.persistence.room.Dao; +import android.arch.persistence.room.Query; +import android.arch.persistence.room.Transaction; + +import org.schabi.newpipe.database.BasicDAO; +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; + +import java.util.List; + +import io.reactivex.Flowable; + +import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_ID; +import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID; +import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE; +import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL; + +@Dao +public abstract class PlaylistRemoteDAO implements BasicDAO { + @Override + @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE) + public abstract Flowable> getAll(); + + @Override + @Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE) + public abstract int deleteAll(); + + @Override + @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + + " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") + public abstract Flowable> listByService(int serviceId); + + @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE " + + REMOTE_PLAYLIST_URL + " = :url AND " + + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") + public abstract Flowable> getPlaylist(long serviceId, String url); + + @Query("SELECT " + REMOTE_PLAYLIST_ID + " FROM " + REMOTE_PLAYLIST_TABLE + + " WHERE " + + REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") + abstract Long getPlaylistIdInternal(long serviceId, String url); + + @Transaction + public long upsert(PlaylistRemoteEntity playlist) { + final Long playlistId = getPlaylistIdInternal(playlist.getServiceId(), playlist.getUrl()); + + if (playlistId == null) { + return insert(playlist); + } else { + playlist.setUid(playlistId); + update(playlist); + return playlistId; + } + } + + @Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE + + " WHERE " + REMOTE_PLAYLIST_ID + " = :playlistId") + public abstract int deletePlaylist(final long playlistId); +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java new file mode 100644 index 000000000..8bf1ea696 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java @@ -0,0 +1,69 @@ +package org.schabi.newpipe.database.playlist.dao; + +import android.arch.persistence.room.Dao; +import android.arch.persistence.room.Query; +import android.arch.persistence.room.Transaction; + +import org.schabi.newpipe.database.BasicDAO; +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; +import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; +import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; +import org.schabi.newpipe.database.stream.model.StreamEntity; + +import java.util.List; + +import io.reactivex.Flowable; + +import static org.schabi.newpipe.database.playlist.PlaylistMetadataEntry.PLAYLIST_STREAM_COUNT; +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.*; +import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.*; +import static org.schabi.newpipe.database.stream.model.StreamEntity.*; + +@Dao +public abstract class PlaylistStreamDAO implements BasicDAO { + @Override + @Query("SELECT * FROM " + PLAYLIST_STREAM_JOIN_TABLE) + public abstract Flowable> getAll(); + + @Override + @Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE) + public abstract int deleteAll(); + + @Override + public Flowable> listByService(int serviceId) { + throw new UnsupportedOperationException(); + } + + @Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE + + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") + public abstract void deleteBatch(final long playlistId); + + @Query("SELECT COALESCE(MAX(" + JOIN_INDEX + "), -1)" + + " FROM " + PLAYLIST_STREAM_JOIN_TABLE + + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") + public abstract Flowable getMaximumIndexOf(final long playlistId); + + @Transaction + @Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN " + + // get ids of streams of the given playlist + "(SELECT " + JOIN_STREAM_ID + "," + JOIN_INDEX + + " FROM " + PLAYLIST_STREAM_JOIN_TABLE + + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId)" + + + // then merge with the stream metadata + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID + + " ORDER BY " + JOIN_INDEX + " ASC") + public abstract Flowable> getOrderedStreamsOf(long playlistId); + + @Transaction + @Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", " + + PLAYLIST_THUMBNAIL_URL + ", " + + "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT + + + " FROM " + PLAYLIST_TABLE + + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE + + " ON " + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID + + " GROUP BY " + JOIN_PLAYLIST_ID + + " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC") + public abstract Flowable> getPlaylistMetadata(); +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java new file mode 100644 index 000000000..a3ec1b5f2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java @@ -0,0 +1,59 @@ +package org.schabi.newpipe.database.playlist.model; + +import android.arch.persistence.room.ColumnInfo; +import android.arch.persistence.room.Entity; +import android.arch.persistence.room.Index; +import android.arch.persistence.room.PrimaryKey; + +import java.util.Date; + +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME; +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE; + +@Entity(tableName = PLAYLIST_TABLE, + indices = {@Index(value = {PLAYLIST_NAME})}) +public class PlaylistEntity { + final public static String PLAYLIST_TABLE = "playlists"; + final public static String PLAYLIST_ID = "uid"; + final public static String PLAYLIST_NAME = "name"; + final public static String PLAYLIST_THUMBNAIL_URL = "thumbnail_url"; + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = PLAYLIST_ID) + private long uid = 0; + + @ColumnInfo(name = PLAYLIST_NAME) + private String name; + + @ColumnInfo(name = PLAYLIST_THUMBNAIL_URL) + private String thumbnailUrl; + + public PlaylistEntity(String name, String thumbnailUrl) { + this.name = name; + this.thumbnailUrl = thumbnailUrl; + } + + public long getUid() { + return uid; + } + + public void setUid(long uid) { + this.uid = uid; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getThumbnailUrl() { + return thumbnailUrl; + } + + public void setThumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java new file mode 100644 index 000000000..486350fc9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java @@ -0,0 +1,139 @@ +package org.schabi.newpipe.database.playlist.model; + +import android.arch.persistence.room.ColumnInfo; +import android.arch.persistence.room.Entity; +import android.arch.persistence.room.Ignore; +import android.arch.persistence.room.Index; +import android.arch.persistence.room.PrimaryKey; + +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.playlist.PlaylistLocalItem; +import org.schabi.newpipe.extractor.playlist.PlaylistInfo; +import org.schabi.newpipe.util.Constants; + +import static org.schabi.newpipe.database.LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM; +import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_NAME; +import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID; +import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE; +import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL; + +@Entity(tableName = REMOTE_PLAYLIST_TABLE, + indices = { + @Index(value = {REMOTE_PLAYLIST_NAME}), + @Index(value = {REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL}, unique = true) + }) +public class PlaylistRemoteEntity implements PlaylistLocalItem { + final public static String REMOTE_PLAYLIST_TABLE = "remote_playlists"; + final public static String REMOTE_PLAYLIST_ID = "uid"; + final public static String REMOTE_PLAYLIST_SERVICE_ID = "service_id"; + final public static String REMOTE_PLAYLIST_NAME = "name"; + final public static String REMOTE_PLAYLIST_URL = "url"; + final public static String REMOTE_PLAYLIST_THUMBNAIL_URL = "thumbnail_url"; + final public static String REMOTE_PLAYLIST_UPLOADER_NAME = "uploader"; + final public static String REMOTE_PLAYLIST_STREAM_COUNT = "stream_count"; + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = REMOTE_PLAYLIST_ID) + private long uid = 0; + + @ColumnInfo(name = REMOTE_PLAYLIST_SERVICE_ID) + private int serviceId = Constants.NO_SERVICE_ID; + + @ColumnInfo(name = REMOTE_PLAYLIST_NAME) + private String name; + + @ColumnInfo(name = REMOTE_PLAYLIST_URL) + private String url; + + @ColumnInfo(name = REMOTE_PLAYLIST_THUMBNAIL_URL) + private String thumbnailUrl; + + @ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME) + private String uploader; + + @ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT) + private Long streamCount; + + public PlaylistRemoteEntity(int serviceId, String name, String url, String thumbnailUrl, + String uploader, Long streamCount) { + this.serviceId = serviceId; + this.name = name; + this.url = url; + this.thumbnailUrl = thumbnailUrl; + this.uploader = uploader; + this.streamCount = streamCount; + } + + @Ignore + public PlaylistRemoteEntity(final PlaylistInfo info) { + this(info.getServiceId(), info.getName(), info.getUrl(), + info.getThumbnailUrl() == null ? info.getUploaderAvatarUrl() : info.getThumbnailUrl(), + info.getUploaderName(), info.getStreamCount()); + } + + public long getUid() { + return uid; + } + + public void setUid(long uid) { + this.uid = uid; + } + + public int getServiceId() { + return serviceId; + } + + public void setServiceId(int serviceId) { + this.serviceId = serviceId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getThumbnailUrl() { + return thumbnailUrl; + } + + public void setThumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getUploader() { + return uploader; + } + + public void setUploader(String uploader) { + this.uploader = uploader; + } + + public Long getStreamCount() { + return streamCount; + } + + public void setStreamCount(Long streamCount) { + this.streamCount = streamCount; + } + + @Override + public LocalItemType getLocalItemType() { + return PLAYLIST_REMOTE_ITEM; + } + + @Override + public String getOrderingName() { + return name; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.java new file mode 100644 index 000000000..a5b2e8248 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.java @@ -0,0 +1,77 @@ +package org.schabi.newpipe.database.playlist.model; + +import android.arch.persistence.room.ColumnInfo; +import android.arch.persistence.room.Entity; +import android.arch.persistence.room.ForeignKey; +import android.arch.persistence.room.Index; + +import org.schabi.newpipe.database.stream.model.StreamEntity; + +import static android.arch.persistence.room.ForeignKey.CASCADE; +import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX; +import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_PLAYLIST_ID; +import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_STREAM_ID; +import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE; + +@Entity(tableName = PLAYLIST_STREAM_JOIN_TABLE, + primaryKeys = {JOIN_PLAYLIST_ID, JOIN_INDEX}, + indices = { + @Index(value = {JOIN_PLAYLIST_ID, JOIN_INDEX}, unique = true), + @Index(value = {JOIN_STREAM_ID}) + }, + foreignKeys = { + @ForeignKey(entity = PlaylistEntity.class, + parentColumns = PlaylistEntity.PLAYLIST_ID, + childColumns = JOIN_PLAYLIST_ID, + onDelete = CASCADE, onUpdate = CASCADE, deferred = true), + @ForeignKey(entity = StreamEntity.class, + parentColumns = StreamEntity.STREAM_ID, + childColumns = JOIN_STREAM_ID, + onDelete = CASCADE, onUpdate = CASCADE, deferred = true) + }) +public class PlaylistStreamEntity { + + final public static String PLAYLIST_STREAM_JOIN_TABLE = "playlist_stream_join"; + final public static String JOIN_PLAYLIST_ID = "playlist_id"; + final public static String JOIN_STREAM_ID = "stream_id"; + final public static String JOIN_INDEX = "join_index"; + + @ColumnInfo(name = JOIN_PLAYLIST_ID) + private long playlistUid; + + @ColumnInfo(name = JOIN_STREAM_ID) + private long streamUid; + + @ColumnInfo(name = JOIN_INDEX) + private int index; + + public PlaylistStreamEntity(final long playlistUid, final long streamUid, final int index) { + this.playlistUid = playlistUid; + this.streamUid = streamUid; + this.index = index; + } + + public long getPlaylistUid() { + return playlistUid; + } + + public long getStreamUid() { + return streamUid; + } + + public int getIndex() { + return index; + } + + public void setPlaylistUid(long playlistUid) { + this.playlistUid = playlistUid; + } + + public void setStreamUid(long streamUid) { + this.streamUid = streamUid; + } + + public void setIndex(int index) { + this.index = index; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.java b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.java new file mode 100644 index 000000000..6909f3397 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.java @@ -0,0 +1,69 @@ +package org.schabi.newpipe.database.stream; + +import android.arch.persistence.room.ColumnInfo; + +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.history.model.StreamHistoryEntity; +import org.schabi.newpipe.database.stream.model.StreamEntity; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.extractor.stream.StreamType; + +import java.util.Date; + +public class StreamStatisticsEntry implements LocalItem { + final public static String STREAM_LATEST_DATE = "latestAccess"; + final public static String STREAM_WATCH_COUNT = "watchCount"; + + @ColumnInfo(name = StreamEntity.STREAM_ID) + final public long uid; + @ColumnInfo(name = StreamEntity.STREAM_SERVICE_ID) + final public int serviceId; + @ColumnInfo(name = StreamEntity.STREAM_URL) + final public String url; + @ColumnInfo(name = StreamEntity.STREAM_TITLE) + final public String title; + @ColumnInfo(name = StreamEntity.STREAM_TYPE) + final public StreamType streamType; + @ColumnInfo(name = StreamEntity.STREAM_DURATION) + final public long duration; + @ColumnInfo(name = StreamEntity.STREAM_UPLOADER) + final public String uploader; + @ColumnInfo(name = StreamEntity.STREAM_THUMBNAIL_URL) + final public String thumbnailUrl; + @ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID) + final public long streamId; + @ColumnInfo(name = StreamStatisticsEntry.STREAM_LATEST_DATE) + final public Date latestAccessDate; + @ColumnInfo(name = StreamStatisticsEntry.STREAM_WATCH_COUNT) + final public long watchCount; + + public StreamStatisticsEntry(long uid, int serviceId, String url, String title, + StreamType streamType, long duration, String uploader, + String thumbnailUrl, long streamId, Date latestAccessDate, + long watchCount) { + this.uid = uid; + this.serviceId = serviceId; + this.url = url; + this.title = title; + this.streamType = streamType; + this.duration = duration; + this.uploader = uploader; + this.thumbnailUrl = thumbnailUrl; + this.streamId = streamId; + this.latestAccessDate = latestAccessDate; + this.watchCount = watchCount; + } + + public StreamInfoItem toStreamInfoItem() { + StreamInfoItem item = new StreamInfoItem(serviceId, url, title, streamType); + item.setDuration(duration); + item.setUploaderName(uploader); + item.setThumbnailUrl(thumbnailUrl); + return item; + } + + @Override + public LocalItemType getLocalItemType() { + return LocalItemType.STATISTIC_STREAM_ITEM; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.java b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.java new file mode 100644 index 000000000..63f9e5940 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.java @@ -0,0 +1,100 @@ +package org.schabi.newpipe.database.stream.dao; + +import android.arch.persistence.room.Dao; +import android.arch.persistence.room.Insert; +import android.arch.persistence.room.OnConflictStrategy; +import android.arch.persistence.room.Query; +import android.arch.persistence.room.Transaction; + +import org.schabi.newpipe.database.BasicDAO; +import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; +import org.schabi.newpipe.database.stream.model.StreamEntity; +import org.schabi.newpipe.database.history.model.StreamHistoryEntity; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; + +import java.util.ArrayList; +import java.util.List; + +import io.reactivex.Flowable; + +import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE; +import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID; +import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_SERVICE_ID; +import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE; +import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_URL; +import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_HISTORY_TABLE; +import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; + +@Dao +public abstract class StreamDAO implements BasicDAO { + @Override + @Query("SELECT * FROM " + STREAM_TABLE) + public abstract Flowable> getAll(); + + @Override + @Query("DELETE FROM " + STREAM_TABLE) + public abstract int deleteAll(); + + @Override + @Query("SELECT * FROM " + STREAM_TABLE + " WHERE " + STREAM_SERVICE_ID + " = :serviceId") + public abstract Flowable> listByService(int serviceId); + + @Query("SELECT * FROM " + STREAM_TABLE + " WHERE " + + STREAM_URL + " = :url AND " + + STREAM_SERVICE_ID + " = :serviceId") + public abstract Flowable> getStream(long serviceId, String url); + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract void silentInsertAllInternal(final List streams); + + @Query("SELECT " + STREAM_ID + " FROM " + STREAM_TABLE + " WHERE " + + STREAM_URL + " = :url AND " + + STREAM_SERVICE_ID + " = :serviceId") + abstract Long getStreamIdInternal(long serviceId, String url); + + @Transaction + public long upsert(StreamEntity stream) { + final Long streamIdCandidate = getStreamIdInternal(stream.getServiceId(), stream.getUrl()); + + if (streamIdCandidate == null) { + return insert(stream); + } else { + stream.setUid(streamIdCandidate); + update(stream); + return streamIdCandidate; + } + } + + @Transaction + public List upsertAll(List streams) { + silentInsertAllInternal(streams); + + final List streamIds = new ArrayList<>(streams.size()); + for (StreamEntity stream : streams) { + final Long streamId = getStreamIdInternal(stream.getServiceId(), stream.getUrl()); + if (streamId == null) { + throw new IllegalStateException("StreamID cannot be null just after insertion."); + } + + streamIds.add(streamId); + stream.setUid(streamId); + } + + update(streams); + return streamIds; + } + + @Query("DELETE FROM " + STREAM_TABLE + " WHERE " + STREAM_ID + + " NOT IN " + + "(SELECT DISTINCT " + STREAM_ID + " FROM " + STREAM_TABLE + + + " LEFT JOIN " + STREAM_HISTORY_TABLE + + " ON " + STREAM_ID + " = " + + StreamHistoryEntity.STREAM_HISTORY_TABLE + "." + StreamHistoryEntity.JOIN_STREAM_ID + + + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE + + " ON " + STREAM_ID + " = " + + PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE + "." + PlaylistStreamEntity.JOIN_STREAM_ID + + ")") + public abstract int deleteOrphans(); +} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java new file mode 100644 index 000000000..1c06f4df9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java @@ -0,0 +1,48 @@ +package org.schabi.newpipe.database.stream.dao; + +import android.arch.persistence.room.Dao; +import android.arch.persistence.room.Insert; +import android.arch.persistence.room.OnConflictStrategy; +import android.arch.persistence.room.Query; +import android.arch.persistence.room.Transaction; + +import org.schabi.newpipe.database.BasicDAO; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; + +import java.util.List; + +import io.reactivex.Flowable; + +import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID; +import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; + +@Dao +public abstract class StreamStateDAO implements BasicDAO { + @Override + @Query("SELECT * FROM " + STREAM_STATE_TABLE) + public abstract Flowable> getAll(); + + @Override + @Query("DELETE FROM " + STREAM_STATE_TABLE) + public abstract int deleteAll(); + + @Override + public Flowable> listByService(int serviceId) { + throw new UnsupportedOperationException(); + } + + @Query("SELECT * FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") + public abstract Flowable> getState(final long streamId); + + @Query("DELETE FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") + public abstract int deleteState(final long streamId); + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract void silentInsertInternal(final StreamStateEntity streamState); + + @Transaction + public long upsert(StreamStateEntity stream) { + silentInsertInternal(stream); + return update(stream); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.java b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.java new file mode 100644 index 000000000..0a9a0bb66 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.java @@ -0,0 +1,153 @@ +package org.schabi.newpipe.database.stream.model; + +import android.arch.persistence.room.ColumnInfo; +import android.arch.persistence.room.Entity; +import android.arch.persistence.room.Ignore; +import android.arch.persistence.room.Index; +import android.arch.persistence.room.PrimaryKey; + +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipe.playlist.PlayQueueItem; +import org.schabi.newpipe.util.Constants; + +import java.io.Serializable; + +import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_SERVICE_ID; +import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE; +import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_URL; + +@Entity(tableName = STREAM_TABLE, + indices = {@Index(value = {STREAM_SERVICE_ID, STREAM_URL}, unique = true)}) +public class StreamEntity implements Serializable { + + final public static String STREAM_TABLE = "streams"; + final public static String STREAM_ID = "uid"; + final public static String STREAM_SERVICE_ID = "service_id"; + final public static String STREAM_URL = "url"; + final public static String STREAM_TITLE = "title"; + final public static String STREAM_TYPE = "stream_type"; + final public static String STREAM_DURATION = "duration"; + final public static String STREAM_UPLOADER = "uploader"; + final public static String STREAM_THUMBNAIL_URL = "thumbnail_url"; + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = STREAM_ID) + private long uid = 0; + + @ColumnInfo(name = STREAM_SERVICE_ID) + private int serviceId = Constants.NO_SERVICE_ID; + + @ColumnInfo(name = STREAM_URL) + private String url; + + @ColumnInfo(name = STREAM_TITLE) + private String title; + + @ColumnInfo(name = STREAM_TYPE) + private StreamType streamType; + + @ColumnInfo(name = STREAM_DURATION) + private Long duration; + + @ColumnInfo(name = STREAM_UPLOADER) + private String uploader; + + @ColumnInfo(name = STREAM_THUMBNAIL_URL) + private String thumbnailUrl; + + public StreamEntity(final int serviceId, final String title, final String url, + final StreamType streamType, final String thumbnailUrl, final String uploader, + final long duration) { + this.serviceId = serviceId; + this.title = title; + this.url = url; + this.streamType = streamType; + this.thumbnailUrl = thumbnailUrl; + this.uploader = uploader; + this.duration = duration; + } + + @Ignore + public StreamEntity(final StreamInfoItem item) { + this(item.getServiceId(), item.getName(), item.getUrl(), item.getStreamType(), item.getThumbnailUrl(), + item.getUploaderName(), item.getDuration()); + } + + @Ignore + public StreamEntity(final StreamInfo info) { + this(info.getServiceId(), info.getName(), info.getUrl(), info.getStreamType(), info.getThumbnailUrl(), + info.getUploaderName(), info.getDuration()); + } + + @Ignore + public StreamEntity(final PlayQueueItem item) { + this(item.getServiceId(), item.getTitle(), item.getUrl(), item.getStreamType(), + item.getThumbnailUrl(), item.getUploader(), item.getDuration()); + } + + public long getUid() { + return uid; + } + + public void setUid(long uid) { + this.uid = uid; + } + + public int getServiceId() { + return serviceId; + } + + public void setServiceId(int serviceId) { + this.serviceId = serviceId; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public StreamType getStreamType() { + return streamType; + } + + public void setStreamType(StreamType type) { + this.streamType = type; + } + + public Long getDuration() { + return duration; + } + + public void setDuration(Long duration) { + this.duration = duration; + } + + public String getUploader() { + return uploader; + } + + public void setUploader(String uploader) { + this.uploader = uploader; + } + + public String getThumbnailUrl() { + return thumbnailUrl; + } + + public void setThumbnailUrl(String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java new file mode 100644 index 000000000..15940a964 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java @@ -0,0 +1,51 @@ +package org.schabi.newpipe.database.stream.model; + + +import android.arch.persistence.room.ColumnInfo; +import android.arch.persistence.room.Entity; +import android.arch.persistence.room.ForeignKey; + +import static android.arch.persistence.room.ForeignKey.CASCADE; +import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID; +import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; + +@Entity(tableName = STREAM_STATE_TABLE, + primaryKeys = {JOIN_STREAM_ID}, + foreignKeys = { + @ForeignKey(entity = StreamEntity.class, + parentColumns = StreamEntity.STREAM_ID, + childColumns = JOIN_STREAM_ID, + onDelete = CASCADE, onUpdate = CASCADE) + }) +public class StreamStateEntity { + final public static String STREAM_STATE_TABLE = "stream_state"; + final public static String JOIN_STREAM_ID = "stream_id"; + final public static String STREAM_PROGRESS_TIME = "progress_time"; + + @ColumnInfo(name = JOIN_STREAM_ID) + private long streamUid; + + @ColumnInfo(name = STREAM_PROGRESS_TIME) + private long progressTime; + + public StreamStateEntity(long streamUid, long progressTime) { + this.streamUid = streamUid; + this.progressTime = progressTime; + } + + public long getStreamUid() { + return streamUid; + } + + public void setStreamUid(long streamUid) { + this.streamUid = streamUid; + } + + public long getProgressTime() { + return progressTime; + } + + public void setProgressTime(long progressTime) { + this.progressTime = progressTime; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.java b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.java index fd6a83d6d..ee6dea9fe 100644 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.java +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.java @@ -1,7 +1,10 @@ package org.schabi.newpipe.database.subscription; import android.arch.persistence.room.Dao; +import android.arch.persistence.room.Insert; +import android.arch.persistence.room.OnConflictStrategy; import android.arch.persistence.room.Query; +import android.arch.persistence.room.Transaction; import org.schabi.newpipe.database.BasicDAO; @@ -11,24 +14,56 @@ import io.reactivex.Flowable; import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID; import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE; +import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_UID; import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_URL; @Dao -public interface SubscriptionDAO extends BasicDAO { +public abstract class SubscriptionDAO implements BasicDAO { @Override @Query("SELECT * FROM " + SUBSCRIPTION_TABLE) - Flowable> getAll(); + public abstract Flowable> getAll(); @Override @Query("DELETE FROM " + SUBSCRIPTION_TABLE) - int deleteAll(); + public abstract int deleteAll(); @Override @Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " + SUBSCRIPTION_SERVICE_ID + " = :serviceId") - Flowable> listByService(int serviceId); + public abstract Flowable> listByService(int serviceId); @Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " + SUBSCRIPTION_URL + " LIKE :url AND " + SUBSCRIPTION_SERVICE_ID + " = :serviceId") - Flowable> getSubscription(int serviceId, String url); + public abstract Flowable> getSubscription(int serviceId, String url); + + @Query("SELECT " + SUBSCRIPTION_UID + " FROM " + SUBSCRIPTION_TABLE + " WHERE " + + SUBSCRIPTION_URL + " LIKE :url AND " + + SUBSCRIPTION_SERVICE_ID + " = :serviceId") + abstract Long getSubscriptionIdInternal(int serviceId, String url); + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract Long insertInternal(final SubscriptionEntity entities); + + @Transaction + public List upsertAll(List entities) { + for (SubscriptionEntity entity : entities) { + Long uid = insertInternal(entity); + + if (uid != -1) { + entity.setUid(uid); + continue; + } + + uid = getSubscriptionIdInternal(entity.getServiceId(), entity.getUrl()); + entity.setUid(uid); + + if (uid == -1) { + throw new IllegalStateException("Invalid subscription id (-1)"); + } + + update(entity); + } + + return entities; + } } diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java index e71088ac9..9328fff6a 100644 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java @@ -5,7 +5,9 @@ import android.arch.persistence.room.Entity; import android.arch.persistence.room.Ignore; import android.arch.persistence.room.Index; import android.arch.persistence.room.PrimaryKey; +import android.support.annotation.NonNull; +import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.util.Constants; @@ -17,6 +19,7 @@ import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCR indices = {@Index(value = {SUBSCRIPTION_SERVICE_ID, SUBSCRIPTION_URL}, unique = true)}) public class SubscriptionEntity { + final static String SUBSCRIPTION_UID = "uid"; final static String SUBSCRIPTION_TABLE = "subscriptions"; final static String SUBSCRIPTION_SERVICE_ID = "service_id"; final static String SUBSCRIPTION_URL = "url"; @@ -50,8 +53,7 @@ public class SubscriptionEntity { return uid; } - /* Keep this package-private since UID should always be auto generated by Room impl */ - void setUid(long uid) { + public void setUid(long uid) { this.uid = uid; } @@ -117,9 +119,18 @@ public class SubscriptionEntity { @Ignore public ChannelInfoItem toChannelInfoItem() { ChannelInfoItem item = new ChannelInfoItem(getServiceId(), getUrl(), getName()); - item.thumbnail_url = getAvatarUrl(); - item.subscriber_count = getSubscriberCount(); - item.description = getDescription(); + item.setThumbnailUrl(getAvatarUrl()); + item.setSubscriberCount(getSubscriberCount()); + item.setDescription(getDescription()); return item; } + + @Ignore + public static SubscriptionEntity from(@NonNull ChannelInfo info) { + SubscriptionEntity result = new SubscriptionEntity(); + result.setServiceId(info.getServiceId()); + result.setUrl(info.getUrl()); + result.setData(info.getName(), info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount()); + return result; + } } diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java index b217b91b3..9bcd0bcb7 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -205,7 +205,7 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck if (DEBUG) Log.d(TAG, "onCheckedChanged() called with: group = [" + group + "], checkedId = [" + checkedId + "]"); switch (checkedId) { case R.id.audio_button: - setupAudioSpinner(currentInfo.audio_streams, streamsSpinner); + setupAudioSpinner(currentInfo.getAudioStreams(), streamsSpinner); break; case R.id.video_button: setupVideoSpinner(sortedStreamVideosList, streamsSpinner); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java index a75c8561f..cb9ce8947 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java @@ -246,13 +246,6 @@ public abstract class BaseStateFragment extends BaseFragment implements ViewC // Utils //////////////////////////////////////////////////////////////////////////*/ - public void setTitle(String title) { - if (DEBUG) Log.d(TAG, "setTitle() called with: title = [" + title + "]"); - if (activity != null && activity.getSupportActionBar() != null) { - activity.getSupportActionBar().setTitle(title); - } - } - protected void openUrlInBrowser(String url) { Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); startActivity(Intent.createChooser(intent, activity.getString(R.string.share_dialog_title))); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java index 3a8c7569c..20607b3a0 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java @@ -29,6 +29,7 @@ import org.schabi.newpipe.extractor.kiosk.KioskList; import org.schabi.newpipe.fragments.list.channel.ChannelFragment; import org.schabi.newpipe.fragments.list.feed.FeedFragment; import org.schabi.newpipe.fragments.list.kiosk.KioskFragment; +import org.schabi.newpipe.fragments.local.bookmark.BookmarkFragment; import org.schabi.newpipe.fragments.subscription.SubscriptionFragment; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.report.UserAction; @@ -46,7 +47,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte // Constants //////////////////////////////////////////////////////////////////////////*/ - private static final int FALLBACK_SERVICE_ID = ServiceList.YouTube.getId(); + private static final int FALLBACK_SERVICE_ID = ServiceList.YouTube.getServiceId(); private static final String FALLBACK_CHANNEL_URL = "https://www.youtube.com/channel/UC-9-kyTW8ZkZNDHQJ6FgpwQ"; private static final String FALLBACK_CHANNEL_NAME = "Music"; private static final String FALLBACK_KIOSK_ID = "Trending"; @@ -84,12 +85,15 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte int channelIcon = ThemeHelper.resolveResourceIdFromAttr(activity, R.attr.ic_channel); int whatsHotIcon = ThemeHelper.resolveResourceIdFromAttr(activity, R.attr.ic_hot); + int bookmarkIcon = ThemeHelper.resolveResourceIdFromAttr(activity, R.attr.ic_bookmark); if (isSubscriptionsPageOnlySelected()) { tabLayout.getTabAt(0).setIcon(channelIcon); + tabLayout.getTabAt(1).setIcon(bookmarkIcon); } else { tabLayout.getTabAt(0).setIcon(whatsHotIcon); tabLayout.getTabAt(1).setIcon(channelIcon); + tabLayout.getTabAt(2).setIcon(bookmarkIcon); } } @@ -102,7 +106,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte super.onCreateOptionsMenu(menu, inflater); if (DEBUG) Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + "], inflater = [" + inflater + "]"); inflater.inflate(R.menu.main_fragment_menu, menu); - SubMenu kioskMenu = menu.addSubMenu(getString(R.string.kiosk)); + SubMenu kioskMenu = menu.addSubMenu(Menu.NONE, Menu.NONE, 200, getString(R.string.kiosk)); try { createKioskMenu(kioskMenu, inflater); } catch (Exception e) { @@ -147,7 +151,6 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte } private class PagerAdapter extends FragmentPagerAdapter { - PagerAdapter(FragmentManager fm) { super(fm); } @@ -158,7 +161,15 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte case 0: return isSubscriptionsPageOnlySelected() ? new SubscriptionFragment() : getMainPageFragment(); case 1: - return new SubscriptionFragment(); + if(PreferenceManager.getDefaultSharedPreferences(getActivity()) + .getString(getString(R.string.main_page_content_key), getString(R.string.blank_page_key)) + .equals(getString(R.string.subscription_page_key))) { + return new BookmarkFragment(); + } else { + return new SubscriptionFragment(); + } + case 2: + return new BookmarkFragment(); default: return new BlankFragment(); } @@ -172,7 +183,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte @Override public int getCount() { - return isSubscriptionsPageOnlySelected() ? 1 : 2; + return isSubscriptionsPageOnlySelected() ? 2 : 3; } } @@ -187,6 +198,8 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte } private Fragment getMainPageFragment() { + if (getActivity() == null) return new BlankFragment(); + try { SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/ActionBarHandler.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/ActionBarHandler.java deleted file mode 100644 index 27bffca2d..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/ActionBarHandler.java +++ /dev/null @@ -1,163 +0,0 @@ -package org.schabi.newpipe.fragments.detail; - -import android.content.SharedPreferences; -import android.preference.PreferenceManager; -import android.support.v7.app.AppCompatActivity; -import android.util.Log; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.widget.AdapterView; -import android.widget.Spinner; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.util.ListHelper; - -import java.util.List; - -/* - * Created by Christian Schabesberger on 18.08.15. - *

- * Copyright (C) Christian Schabesberger 2015 - * DetailsMenuHandler.java is part of NewPipe. - *

- * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - *

- * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - *

- * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - - -@SuppressWarnings("WeakerAccess") -class ActionBarHandler { - private static final String TAG = "ActionBarHandler"; - - private AppCompatActivity activity; - private int selectedVideoStream = -1; - - private SharedPreferences defaultPreferences; - - private Menu menu; - - // Only callbacks are listed here, there are more actions which don't need a callback. - // those are edited directly. Typically VideoDetailFragment will implement those callbacks. - private OnActionListener onShareListener; - private OnActionListener onOpenInBrowserListener; - private OnActionListener onDownloadListener; - private OnActionListener onPlayWithKodiListener; - - // Triggered when a stream related action is triggered. - public interface OnActionListener { - void onActionSelected(int selectedStreamId); - } - - public ActionBarHandler(AppCompatActivity activity) { - this.activity = activity; - } - - public void setupStreamList(final List videoStreams, Spinner toolbarSpinner) { - if (activity == null) return; - - selectedVideoStream = ListHelper.getDefaultResolutionIndex(activity, videoStreams); - - boolean isExternalPlayerEnabled = PreferenceManager.getDefaultSharedPreferences(activity).getBoolean(activity.getString(R.string.use_external_video_player_key), false); - toolbarSpinner.setAdapter(new SpinnerToolbarAdapter(activity, videoStreams, isExternalPlayerEnabled)); - toolbarSpinner.setSelection(selectedVideoStream); - toolbarSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { - @Override - public void onItemSelected(AdapterView parent, View view, int position, long id) { - selectedVideoStream = position; - } - - @Override - public void onNothingSelected(AdapterView parent) { - } - }); - - } - - public void setupMenu(Menu menu, MenuInflater inflater) { - this.menu = menu; - - // CAUTION set item properties programmatically otherwise it would not be accepted by - // appcompat itemsinflater.inflate(R.menu.videoitem_detail, menu); - - defaultPreferences = PreferenceManager.getDefaultSharedPreferences(activity); - inflater.inflate(R.menu.video_detail_menu, menu); - - updateItemsVisibility(); - } - - public void updateItemsVisibility(){ - showPlayWithKodiAction(defaultPreferences.getBoolean(activity.getString(R.string.show_play_with_kodi_key), false)); - } - - public boolean onItemSelected(MenuItem item) { - int id = item.getItemId(); - switch (id) { - case R.id.menu_item_share: { - if (onShareListener != null) { - onShareListener.onActionSelected(selectedVideoStream); - } - return true; - } - case R.id.menu_item_openInBrowser: { - if (onOpenInBrowserListener != null) { - onOpenInBrowserListener.onActionSelected(selectedVideoStream); - } - return true; - } - case R.id.menu_item_download: - if (onDownloadListener != null) { - onDownloadListener.onActionSelected(selectedVideoStream); - } - return true; - case R.id.action_play_with_kodi: - if (onPlayWithKodiListener != null) { - onPlayWithKodiListener.onActionSelected(selectedVideoStream); - } - return true; - default: - Log.e(TAG, "Menu Item not known"); - } - return false; - } - - public int getSelectedVideoStream() { - return selectedVideoStream; - } - - public void setOnShareListener(OnActionListener listener) { - onShareListener = listener; - } - - public void setOnOpenInBrowserListener(OnActionListener listener) { - onOpenInBrowserListener = listener; - } - - public void setOnDownloadListener(OnActionListener listener) { - onDownloadListener = listener; - } - - public void setOnPlayWithKodiListener(OnActionListener listener) { - onPlayWithKodiListener = listener; - } - - public void showDownloadAction(boolean visible) { - menu.findItem(R.id.menu_item_download).setVisible(visible); - } - - public void showPlayWithKodiAction(boolean visible) { - menu.findItem(R.id.action_play_with_kodi).setVisible(visible); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index c7b61eceb..74e561f99 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -31,6 +31,7 @@ import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; +import android.widget.AdapterView; import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.ImageView; @@ -42,6 +43,7 @@ import android.widget.Toast; import com.nirhart.parallaxscroll.views.ParallaxScrollView; import com.nostra13.universalimageloader.core.assist.FailReason; +import com.nostra13.universalimageloader.core.listener.ImageLoadingListener; import com.nostra13.universalimageloader.core.listener.SimpleImageLoadingListener; import org.schabi.newpipe.R; @@ -55,10 +57,11 @@ import org.schabi.newpipe.extractor.services.youtube.YoutubeStreamExtractor; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.BaseStateFragment; -import org.schabi.newpipe.history.HistoryListener; +import org.schabi.newpipe.fragments.local.dialog.PlaylistAppendDialog; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.info_list.InfoItemDialog; import org.schabi.newpipe.player.MainVideoPlayer; @@ -71,10 +74,12 @@ import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.ExtractorHelper; +import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.InfoCache; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.ThemeHelper; @@ -88,19 +93,21 @@ import io.reactivex.Single; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.Disposable; -import io.reactivex.functions.Consumer; -import io.reactivex.functions.Function; import io.reactivex.schedulers.Schedulers; import static org.schabi.newpipe.util.AnimationUtils.animateView; -public class VideoDetailFragment extends BaseStateFragment implements BackPressable, SharedPreferences.OnSharedPreferenceChangeListener, View.OnClickListener, View.OnLongClickListener { +public class VideoDetailFragment + extends BaseStateFragment + implements BackPressable, + SharedPreferences.OnSharedPreferenceChangeListener, + View.OnClickListener, + View.OnLongClickListener { public static final String AUTO_PLAY = "auto_play"; // Amount of videos to show on start private static final int INITIAL_RELATED_VIDEOS = 8; - private ActionBarHandler actionBarHandler; private ArrayList sortedStreamVideosList; private InfoItemBuilder infoItemBuilder = null; @@ -125,10 +132,14 @@ public class VideoDetailFragment extends BaseStateFragment implement private Disposable currentWorker; private CompositeDisposable disposables = new CompositeDisposable(); + private int selectedVideoStream = -1; + /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ + private Menu menu; + private Spinner spinnerToolbar; private ParallaxScrollView parallaxScrollRootView; @@ -145,7 +156,10 @@ public class VideoDetailFragment extends BaseStateFragment implement private TextView detailControlsBackground; private TextView detailControlsPopup; + private TextView detailControlsAddToPlaylist; + private TextView detailControlsDownload; private TextView appendControlsDetail; + private TextView detailDurationView; private LinearLayout videoDescriptionRootLayout; private TextView videoUploadDateView; @@ -166,6 +180,7 @@ public class VideoDetailFragment extends BaseStateFragment implement private LinearLayout relatedStreamsView; private ImageButton relatedStreamExpandButton; + /*////////////////////////////////////////////////////////////////////////*/ public static VideoDetailFragment getInstance(int serviceId, String videoUrl, String name) { @@ -183,8 +198,10 @@ public class VideoDetailFragment extends BaseStateFragment implement super.onCreate(savedInstanceState); setHasOptionsMenu(true); - showRelatedStreams = PreferenceManager.getDefaultSharedPreferences(activity).getBoolean(getString(R.string.show_next_video_key), true); - PreferenceManager.getDefaultSharedPreferences(activity).registerOnSharedPreferenceChangeListener(this); + showRelatedStreams = PreferenceManager.getDefaultSharedPreferences(activity) + .getBoolean(getString(R.string.show_next_video_key), true); + PreferenceManager.getDefaultSharedPreferences(activity) + .registerOnSharedPreferenceChangeListener(this); } @Override @@ -205,10 +222,13 @@ public class VideoDetailFragment extends BaseStateFragment implement if (updateFlags != 0) { if (!isLoading.get() && currentInfo != null) { if ((updateFlags & RELATED_STREAMS_UPDATE_FLAG) != 0) initRelatedVideos(currentInfo); - if ((updateFlags & RESOLUTIONS_MENU_UPDATE_FLAG) != 0) setupActionBarHandler(currentInfo); + if ((updateFlags & RESOLUTIONS_MENU_UPDATE_FLAG) != 0) setupActionBar(currentInfo); } - if ((updateFlags & TOOLBAR_ITEMS_UPDATE_FLAG) != 0 && actionBarHandler != null) actionBarHandler.updateItemsVisibility(); + if ((updateFlags & TOOLBAR_ITEMS_UPDATE_FLAG) != 0 + && menu != null) { + updateMenuItemVisibility(); + } updateFlags = 0; } @@ -221,7 +241,8 @@ public class VideoDetailFragment extends BaseStateFragment implement @Override public void onDestroy() { super.onDestroy(); - PreferenceManager.getDefaultSharedPreferences(activity).unregisterOnSharedPreferenceChangeListener(this); + PreferenceManager.getDefaultSharedPreferences(activity) + .unregisterOnSharedPreferenceChangeListener(this); if (currentWorker != null) currentWorker.dispose(); if (disposables != null) disposables.clear(); @@ -282,7 +303,8 @@ public class VideoDetailFragment extends BaseStateFragment implement // Check if the next video label and video is visible, // if it is, include the two elements in the next check int nextCount = currentInfo != null && currentInfo.getNextVideo() != null ? 2 : 0; - if (relatedStreamsView != null && relatedStreamsView.getChildCount() > INITIAL_RELATED_VIDEOS + nextCount) { + if (relatedStreamsView != null + && relatedStreamsView.getChildCount() > INITIAL_RELATED_VIDEOS + nextCount) { outState.putSerializable(WAS_RELATED_EXPANDED_KEY, true); } @@ -302,7 +324,7 @@ public class VideoDetailFragment extends BaseStateFragment implement if (serializable instanceof StreamInfo) { //noinspection unchecked currentInfo = (StreamInfo) serializable; - InfoCache.getInstance().putInfo(currentInfo); + InfoCache.getInstance().putInfo(serviceId, url, currentInfo); } serializable = savedState.getSerializable(STACK_KEY); @@ -327,6 +349,30 @@ public class VideoDetailFragment extends BaseStateFragment implement case R.id.detail_controls_popup: openPopupPlayer(false); break; + case R.id.detail_controls_playlist_append: + if (getFragmentManager() != null && currentInfo != null) { + PlaylistAppendDialog.fromStreamInfo(currentInfo) + .show(getFragmentManager(), TAG); + } + break; + case R.id.detail_controls_download: + if (!PermissionHelper.checkStoragePermissions(activity)) { + return; + } + + try { + DownloadDialog downloadDialog = + DownloadDialog.newInstance(currentInfo, + sortedStreamVideosList, + selectedVideoStream); + downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog"); + } catch (Exception e) { + Toast.makeText(activity, + R.string.could_not_setup_download_menu, + Toast.LENGTH_LONG).show(); + e.printStackTrace(); + } + break; case R.id.detail_uploader_root_layout: if (TextUtils.isEmpty(currentInfo.getUploaderUrl())) { Log.w(TAG, "Can't open channel because we got no channel URL"); @@ -339,7 +385,8 @@ public class VideoDetailFragment extends BaseStateFragment implement } break; case R.id.detail_thumbnail_root_layout: - if (currentInfo.video_streams.isEmpty() && currentInfo.video_only_streams.isEmpty()) { + if (currentInfo.getVideoStreams().isEmpty() + && currentInfo.getVideoOnlyStreams().isEmpty()) { openBackgroundPlayer(false); } else { openVideoPlayer(); @@ -390,8 +437,10 @@ public class VideoDetailFragment extends BaseStateFragment implement int initialCount = INITIAL_RELATED_VIDEOS + nextCount; if (relatedStreamsView.getChildCount() > initialCount) { - relatedStreamsView.removeViews(initialCount, relatedStreamsView.getChildCount() - (initialCount)); - relatedStreamExpandButton.setImageDrawable(ContextCompat.getDrawable(activity, ThemeHelper.resolveResourceIdFromAttr(activity, R.attr.expand))); + relatedStreamsView.removeViews(initialCount, + relatedStreamsView.getChildCount() - (initialCount)); + relatedStreamExpandButton.setImageDrawable(ContextCompat.getDrawable( + activity, ThemeHelper.resolveResourceIdFromAttr(activity, R.attr.expand))); return; } @@ -401,7 +450,9 @@ public class VideoDetailFragment extends BaseStateFragment implement //Log.d(TAG, "i = " + i); relatedStreamsView.addView(infoItemBuilder.buildView(relatedStreamsView, item)); } - relatedStreamExpandButton.setImageDrawable(ContextCompat.getDrawable(activity, ThemeHelper.resolveResourceIdFromAttr(activity, R.attr.collapse))); + relatedStreamExpandButton.setImageDrawable( + ContextCompat.getDrawable(activity, + ThemeHelper.resolveResourceIdFromAttr(activity, R.attr.collapse))); } /*////////////////////////////////////////////////////////////////////////// @@ -411,7 +462,6 @@ public class VideoDetailFragment extends BaseStateFragment implement @Override protected void initViews(View rootView, Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); - spinnerToolbar = activity.findViewById(R.id.toolbar).findViewById(R.id.toolbar_spinner); parallaxScrollRootView = rootView.findViewById(R.id.detail_main_content); @@ -429,7 +479,10 @@ public class VideoDetailFragment extends BaseStateFragment implement detailControlsBackground = rootView.findViewById(R.id.detail_controls_background); detailControlsPopup = rootView.findViewById(R.id.detail_controls_popup); + detailControlsAddToPlaylist = rootView.findViewById(R.id.detail_controls_playlist_append); + detailControlsDownload = rootView.findViewById(R.id.detail_controls_download); appendControlsDetail = rootView.findViewById(R.id.touch_append_detail); + detailDurationView = rootView.findViewById(R.id.detail_duration_view); videoDescriptionRootLayout = rootView.findViewById(R.id.detail_description_root_layout); videoUploadDateView = rootView.findViewById(R.id.detail_upload_date_view); @@ -454,7 +507,6 @@ public class VideoDetailFragment extends BaseStateFragment implement relatedStreamExpandButton = rootView.findViewById(R.id.detail_related_streams_expand); - actionBarHandler = new ActionBarHandler(activity); infoItemBuilder = new InfoItemBuilder(activity); setHeightThumbnail(); } @@ -462,7 +514,7 @@ public class VideoDetailFragment extends BaseStateFragment implement @Override protected void initListeners() { super.initListeners(); - infoItemBuilder.setOnStreamSelectedListener(new InfoItemBuilder.OnInfoItemSelectedListener() { + infoItemBuilder.setOnStreamSelectedListener(new OnClickGesture() { @Override public void selected(StreamInfoItem selectedItem) { selectAndLoadVideo(selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName()); @@ -479,6 +531,8 @@ public class VideoDetailFragment extends BaseStateFragment implement thumbnailBackgroundButton.setOnClickListener(this); detailControlsBackground.setOnClickListener(this); detailControlsPopup.setOnClickListener(this); + detailControlsAddToPlaylist.setOnClickListener(this); + detailControlsDownload.setOnClickListener(this); relatedStreamExpandButton.setOnClickListener(this); detailControlsBackground.setLongClickable(true); @@ -498,19 +552,16 @@ public class VideoDetailFragment extends BaseStateFragment implement context.getResources().getString(R.string.enqueue_on_popup) }; - final DialogInterface.OnClickListener actions = new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialogInterface, int i) { - switch (i) { - case 0: - NavigationHelper.enqueueOnBackgroundPlayer(context, new SinglePlayQueue(item)); - break; - case 1: - NavigationHelper.enqueueOnPopupPlayer(getActivity(), new SinglePlayQueue(item)); - break; - default: - break; - } + final DialogInterface.OnClickListener actions = (DialogInterface dialogInterface, int i) -> { + switch (i) { + case 0: + NavigationHelper.enqueueOnBackgroundPlayer(context, new SinglePlayQueue(item)); + break; + case 1: + NavigationHelper.enqueueOnPopupPlayer(getActivity(), new SinglePlayQueue(item)); + break; + default: + break; } }; @@ -518,37 +569,39 @@ public class VideoDetailFragment extends BaseStateFragment implement } private View.OnTouchListener getOnControlsTouchListener() { - return new View.OnTouchListener() { - @Override - public boolean onTouch(View view, MotionEvent motionEvent) { - if (!PreferenceManager.getDefaultSharedPreferences(activity).getBoolean(getString(R.string.show_hold_to_append_key), true)) return false; - - if (motionEvent.getAction() == MotionEvent.ACTION_DOWN) { - animateView(appendControlsDetail, true, 250, 0, new Runnable() { - @Override - public void run() { - animateView(appendControlsDetail, false, 1500, 1000); - } - }); - } + return (View view, MotionEvent motionEvent) -> { + if (!PreferenceManager.getDefaultSharedPreferences(activity) + .getBoolean(getString(R.string.show_hold_to_append_key), true)) { return false; } + + if (motionEvent.getAction() == MotionEvent.ACTION_DOWN) { + animateView(appendControlsDetail, true, 250, 0, () -> + animateView(appendControlsDetail, false, 1500, 1000)); + } + return false; }; } - private void initThumbnailViews(StreamInfo info) { + private void initThumbnailViews(@NonNull StreamInfo info) { thumbnailImageView.setImageResource(R.drawable.dummy_thumbnail_dark); if (!TextUtils.isEmpty(info.getThumbnailUrl())) { - imageLoader.displayImage(info.getThumbnailUrl(), thumbnailImageView, DISPLAY_THUMBNAIL_OPTIONS, new SimpleImageLoadingListener() { + final String infoServiceName = NewPipe.getNameOfService(info.getServiceId()); + final ImageLoadingListener onFailListener = new SimpleImageLoadingListener() { @Override public void onLoadingFailed(String imageUri, View view, FailReason failReason) { - ErrorActivity.reportError(activity, failReason.getCause(), null, activity.findViewById(android.R.id.content), ErrorActivity.ErrorInfo.make(UserAction.LOAD_IMAGE, NewPipe.getNameOfService(currentInfo.getServiceId()), imageUri, R.string.could_not_load_thumbnails)); + showSnackBarError(failReason.getCause(), UserAction.LOAD_IMAGE, + infoServiceName, imageUri, R.string.could_not_load_thumbnails); } - }); + }; + + imageLoader.displayImage(info.getThumbnailUrl(), thumbnailImageView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS, onFailListener); } - + if (!TextUtils.isEmpty(info.getUploaderAvatarUrl())) { - imageLoader.displayImage(info.getUploaderAvatarUrl(), uploaderThumb, DISPLAY_AVATAR_OPTIONS); + imageLoader.displayImage(info.getUploaderAvatarUrl(), uploaderThumb, + ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS); } } @@ -557,14 +610,18 @@ public class VideoDetailFragment extends BaseStateFragment implement if (info.getNextVideo() != null && showRelatedStreams) { nextStreamTitle.setVisibility(View.VISIBLE); - relatedStreamsView.addView(infoItemBuilder.buildView(relatedStreamsView, info.getNextVideo())); + relatedStreamsView.addView( + infoItemBuilder.buildView(relatedStreamsView, info.getNextVideo())); relatedStreamsView.addView(getSeparatorView()); relatedStreamRootLayout.setVisibility(View.VISIBLE); } else nextStreamTitle.setVisibility(View.GONE); - if (info.related_streams != null && !info.related_streams.isEmpty() && showRelatedStreams) { + if (info.getRelatedStreams() != null + && !info.getRelatedStreams().isEmpty() && showRelatedStreams) { //long first = System.nanoTime(), each; - int to = info.getRelatedStreams().size() >= INITIAL_RELATED_VIDEOS ? INITIAL_RELATED_VIDEOS : info.getRelatedStreams().size(); + int to = info.getRelatedStreams().size() >= INITIAL_RELATED_VIDEOS + ? INITIAL_RELATED_VIDEOS + : info.getRelatedStreams().size(); for (int i = 0; i < to; i++) { InfoItem item = info.getRelatedStreams().get(i); //each = System.nanoTime(); @@ -576,7 +633,8 @@ public class VideoDetailFragment extends BaseStateFragment implement relatedStreamRootLayout.setVisibility(View.VISIBLE); relatedStreamExpandButton.setVisibility(View.VISIBLE); - relatedStreamExpandButton.setImageDrawable(ContextCompat.getDrawable(activity, ThemeHelper.resolveResourceIdFromAttr(activity, R.attr.expand))); + relatedStreamExpandButton.setImageDrawable(ContextCompat.getDrawable( + activity, ThemeHelper.resolveResourceIdFromAttr(activity, R.attr.expand))); } else { if (info.getNextVideo() == null) relatedStreamRootLayout.setVisibility(View.GONE); relatedStreamExpandButton.setVisibility(View.GONE); @@ -589,7 +647,15 @@ public class VideoDetailFragment extends BaseStateFragment implement @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - actionBarHandler.setupMenu(menu, inflater); + this.menu = menu; + + // CAUTION set item properties programmatically otherwise it would not be accepted by + // appcompat itemsinflater.inflate(R.menu.videoitem_detail, menu); + + inflater.inflate(R.menu.video_detail_menu, menu); + + updateMenuItemVisibility(); + ActionBar supportActionBar = activity.getSupportActionBar(); if (supportActionBar != null) { supportActionBar.setDisplayHomeAsUpEnabled(true); @@ -597,73 +663,86 @@ public class VideoDetailFragment extends BaseStateFragment implement } } + private void updateMenuItemVisibility() { + + // show kodi if set in settings + menu.findItem(R.id.action_play_with_kodi).setVisible( + PreferenceManager.getDefaultSharedPreferences(activity).getBoolean( + activity.getString(R.string.show_play_with_kodi_key), false)); + } + @Override public boolean onOptionsItemSelected(MenuItem item) { - return (!isLoading.get() && actionBarHandler.onItemSelected(item)) || super.onOptionsItemSelected(item); + if(isLoading.get()) { + // if is still loading block menu + return true; + } + + int id = item.getItemId(); + switch (id) { + case R.id.menu_item_share: { + if(currentInfo != null) { + shareUrl(currentInfo.getName(), url); + } else { + shareUrl(url, url); + } + return true; + } + case R.id.menu_item_openInBrowser: { + openUrlInBrowser(url); + return true; + } + case R.id.action_play_with_kodi: + try { + NavigationHelper.playWithKore(activity, Uri.parse( + url.replace("https", "http"))); + } catch (Exception e) { + if(DEBUG) Log.i(TAG, "Failed to start kore", e); + showInstallKoreDialog(activity); + } + return true; + default: + return super.onOptionsItemSelected(item); + } } private static void showInstallKoreDialog(final Context context) { final AlertDialog.Builder builder = new AlertDialog.Builder(context); builder.setMessage(R.string.kore_not_found) - .setPositiveButton(R.string.install, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - NavigationHelper.installKore(context); - } - }) - .setNegativeButton(R.string.cancel, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - - } - }); + .setPositiveButton(R.string.install, (DialogInterface dialog, int which) -> + NavigationHelper.installKore(context)) + .setNegativeButton(R.string.cancel, (DialogInterface dialog, int which) -> {}); builder.create().show(); } - private void setupActionBarHandler(final StreamInfo info) { + private void setupActionBarOnError(final String url) { + if (DEBUG) Log.d(TAG, "setupActionBarHandlerOnError() called with: url = [" + url + "]"); + Log.e("-----", "missing code"); + } + + private void setupActionBar(final StreamInfo info) { if (DEBUG) Log.d(TAG, "setupActionBarHandler() called with: info = [" + info + "]"); - sortedStreamVideosList = new ArrayList<>(ListHelper.getSortedStreamVideosList(activity, info.getVideoStreams(), info.getVideoOnlyStreams(), false)); - actionBarHandler.setupStreamList(sortedStreamVideosList, spinnerToolbar); - actionBarHandler.setOnShareListener(selectedStreamId -> shareUrl(info.name, info.url)); + sortedStreamVideosList = new ArrayList<>(ListHelper.getSortedStreamVideosList( + activity, info.getVideoStreams(), info.getVideoOnlyStreams(), false)); - actionBarHandler.setOnOpenInBrowserListener(new ActionBarHandler.OnActionListener() { + selectedVideoStream = ListHelper.getDefaultResolutionIndex(activity, sortedStreamVideosList); + + boolean isExternalPlayerEnabled = PreferenceManager.getDefaultSharedPreferences(activity) + .getBoolean(activity.getString(R.string.use_external_video_player_key), false); + spinnerToolbar.setAdapter(new SpinnerToolbarAdapter(activity, sortedStreamVideosList, + isExternalPlayerEnabled)); + spinnerToolbar.setSelection(selectedVideoStream); + spinnerToolbar.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override - public void onActionSelected(int selectedStreamId) { - openUrlInBrowser(info.getUrl()); + public void onItemSelected(AdapterView parent, View view, int position, long id) { + selectedVideoStream = position; + } + + @Override + public void onNothingSelected(AdapterView parent) { } }); - actionBarHandler.setOnPlayWithKodiListener(new ActionBarHandler.OnActionListener() { - @Override - public void onActionSelected(int selectedStreamId) { - try { - NavigationHelper.playWithKore(activity, Uri.parse(info.getUrl().replace("https", "http"))); - if(activity instanceof HistoryListener) { - ((HistoryListener) activity).onVideoPlayed(info, null); - } - } catch (Exception e) { - if(DEBUG) Log.i(TAG, "Failed to start kore", e); - showInstallKoreDialog(activity); - } - } - }); - - actionBarHandler.setOnDownloadListener(new ActionBarHandler.OnActionListener() { - @Override - public void onActionSelected(int selectedStreamId) { - if (!PermissionHelper.checkStoragePermissions(activity)) { - return; - } - - try { - DownloadDialog downloadDialog = DownloadDialog.newInstance(info, sortedStreamVideosList, selectedStreamId); - downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog"); - } catch (Exception e) { - Toast.makeText(activity, R.string.could_not_setup_download_menu, Toast.LENGTH_LONG).show(); - e.printStackTrace(); - } - } - }); } /*////////////////////////////////////////////////////////////////////////// @@ -698,7 +777,10 @@ public class VideoDetailFragment extends BaseStateFragment implement public void setTitleToUrl(int serviceId, String videoUrl, String name) { if (name != null && !name.isEmpty()) { for (StackItem stackItem : stack) { - if (stack.peek().getServiceId() == serviceId && stackItem.getUrl().equals(videoUrl)) stackItem.setTitle(name); + if (stack.peek().getServiceId() == serviceId + && stackItem.getUrl().equals(videoUrl)) { + stackItem.setTitle(name); + } } } } @@ -740,18 +822,18 @@ public class VideoDetailFragment extends BaseStateFragment implement pushToStack(serviceId, url, name); showLoading(); - Log.d(TAG, "prepareAndHandleInfo() called parallaxScrollRootView.getScrollY(): " + parallaxScrollRootView.getScrollY()); + Log.d(TAG, "prepareAndHandleInfo() called parallaxScrollRootView.getScrollY(): " + + parallaxScrollRootView.getScrollY()); final boolean greaterThanThreshold = parallaxScrollRootView.getScrollY() > (int) (getResources().getDisplayMetrics().heightPixels * .1f); if (scrollToTop) parallaxScrollRootView.smoothScrollTo(0, 0); - animateView(contentRootLayoutHiding, false, greaterThanThreshold ? 250 : 0, 0, new Runnable() { - @Override - public void run() { - handleResult(info); - showContentWithAnimation(120, 0, .01f); - } - }); + animateView(contentRootLayoutHiding, + false, + greaterThanThreshold ? 250 : 0, 0, () -> { + handleResult(info); + showContentWithAnimation(120, 0, .01f); + }); } protected void prepareAndLoadInfo() { @@ -770,20 +852,14 @@ public class VideoDetailFragment extends BaseStateFragment implement currentWorker = ExtractorHelper.getStreamInfo(serviceId, url, forceLoad) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Consumer() { - @Override - public void accept(@NonNull StreamInfo result) throws Exception { - isLoading.set(false); - currentInfo = result; - showContentWithAnimation(120, 0, 0); - handleResult(result); - } - }, new Consumer() { - @Override - public void accept(@NonNull Throwable throwable) throws Exception { - isLoading.set(false); - onError(throwable); - } + .subscribe((@NonNull StreamInfo result) -> { + isLoading.set(false); + currentInfo = result; + showContentWithAnimation(120, 0, 0); + handleResult(result); + }, (@NonNull Throwable throwable) -> { + isLoading.set(false); + onError(throwable); }); } @@ -792,11 +868,8 @@ public class VideoDetailFragment extends BaseStateFragment implement //////////////////////////////////////////////////////////////////////////*/ private void openBackgroundPlayer(final boolean append) { - AudioStream audioStream = currentInfo.getAudioStreams().get(ListHelper.getDefaultAudioFormat(activity, currentInfo.getAudioStreams())); - - if (activity instanceof HistoryListener) { - ((HistoryListener) activity).onAudioPlayed(currentInfo, audioStream); - } + AudioStream audioStream = currentInfo.getAudioStreams() + .get(ListHelper.getDefaultAudioFormat(activity, currentInfo.getAudioStreams())); boolean useExternalAudioPlayer = PreferenceManager.getDefaultSharedPreferences(activity) .getBoolean(activity.getString(R.string.use_external_audio_player_key), false); @@ -804,7 +877,10 @@ public class VideoDetailFragment extends BaseStateFragment implement if (!useExternalAudioPlayer && android.os.Build.VERSION.SDK_INT >= 16) { openNormalBackgroundPlayer(append); } else { - NavigationHelper.playOnExternalPlayer(activity, currentInfo.getName(), currentInfo.getUploaderName(), audioStream); + NavigationHelper.playOnExternalPlayer(activity, + currentInfo.getName(), + currentInfo.getUploaderName(), + audioStream); } } @@ -814,10 +890,6 @@ public class VideoDetailFragment extends BaseStateFragment implement return; } - if (activity instanceof HistoryListener) { - ((HistoryListener) activity).onVideoPlayed(currentInfo, getSelectedVideoStream()); - } - final PlayQueue itemQueue = new SinglePlayQueue(currentInfo); if (append) { NavigationHelper.enqueueOnPopupPlayer(activity, itemQueue); @@ -833,12 +905,12 @@ public class VideoDetailFragment extends BaseStateFragment implement private void openVideoPlayer() { VideoStream selectedVideoStream = getSelectedVideoStream(); - if (activity instanceof HistoryListener) { - ((HistoryListener) activity).onVideoPlayed(currentInfo, selectedVideoStream); - } - - if (PreferenceManager.getDefaultSharedPreferences(activity).getBoolean(this.getString(R.string.use_external_video_player_key), false)) { - NavigationHelper.playOnExternalPlayer(activity, currentInfo.getName(), currentInfo.getUploaderName(), selectedVideoStream); + if (PreferenceManager.getDefaultSharedPreferences(activity) + .getBoolean(this.getString(R.string.use_external_video_player_key), false)) { + NavigationHelper.playOnExternalPlayer(activity, + currentInfo.getName(), + currentInfo.getUploaderName(), + selectedVideoStream); } else { openNormalPlayer(selectedVideoStream); } @@ -859,7 +931,10 @@ public class VideoDetailFragment extends BaseStateFragment implement if (!useOldPlayer) { // ExoPlayer final PlayQueue playQueue = new SinglePlayQueue(currentInfo); - mIntent = NavigationHelper.getPlayerIntent(activity, MainVideoPlayer.class, playQueue, getSelectedVideoStream().getResolution()); + mIntent = NavigationHelper.getPlayerIntent(activity, + MainVideoPlayer.class, + playQueue, + getSelectedVideoStream().getResolution()); } else { // Internal Player mIntent = new Intent(activity, PlayVideoActivity.class) @@ -880,7 +955,7 @@ public class VideoDetailFragment extends BaseStateFragment implement } private VideoStream getSelectedVideoStream() { - return sortedStreamVideosList.get(actionBarHandler.getSelectedVideoStream()); + return sortedStreamVideosList.get(selectedVideoStream); } private void prepareDescription(final String descriptionHtml) { @@ -889,35 +964,32 @@ public class VideoDetailFragment extends BaseStateFragment implement } disposables.add(Single.just(descriptionHtml) - .map(new Function() { - @Override - public Spanned apply(@io.reactivex.annotations.NonNull String description) throws Exception { - Spanned parsedDescription; - if (Build.VERSION.SDK_INT >= 24) { - parsedDescription = Html.fromHtml(description, 0); - } else { - //noinspection deprecation - parsedDescription = Html.fromHtml(description); - } - return parsedDescription; + .map((@io.reactivex.annotations.NonNull String description) -> { + Spanned parsedDescription; + if (Build.VERSION.SDK_INT >= 24) { + parsedDescription = Html.fromHtml(description, 0); + } else { + //noinspection deprecation + parsedDescription = Html.fromHtml(description); } + return parsedDescription; }) .subscribeOn(Schedulers.computation()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Consumer() { - @Override - public void accept(@io.reactivex.annotations.NonNull Spanned spanned) throws Exception { - videoDescriptionView.setText(spanned); - videoDescriptionView.setVisibility(View.VISIBLE); - } + .subscribe((@io.reactivex.annotations.NonNull Spanned spanned) -> { + videoDescriptionView.setText(spanned); + videoDescriptionView.setVisibility(View.VISIBLE); })); } private View getSeparatorView() { View separator = new View(activity); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 1); - int m8 = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8, getResources().getDisplayMetrics()); - int m5 = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 5, getResources().getDisplayMetrics()); + LinearLayout.LayoutParams params = + new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 1); + int m8 = (int) TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 8, getResources().getDisplayMetrics()); + int m5 = (int) TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 5, getResources().getDisplayMetrics()); params.setMargins(m8, m5, m8, m5); separator.setLayoutParams(params); @@ -931,13 +1003,17 @@ public class VideoDetailFragment extends BaseStateFragment implement private void setHeightThumbnail() { final DisplayMetrics metrics = getResources().getDisplayMetrics(); boolean isPortrait = metrics.heightPixels > metrics.widthPixels; - int height = isPortrait ? (int) (metrics.widthPixels / (16.0f / 9.0f)) : (int) (metrics.heightPixels / 2f); - thumbnailImageView.setScaleType(isPortrait ? ImageView.ScaleType.CENTER_CROP : ImageView.ScaleType.FIT_CENTER); - thumbnailImageView.setLayoutParams(new FrameLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, height)); + int height = isPortrait + ? (int) (metrics.widthPixels / (16.0f / 9.0f)) + : (int) (metrics.heightPixels / 2f); + thumbnailImageView.setLayoutParams( + new FrameLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, height)); thumbnailImageView.setMinimumHeight(height); } - private void showContentWithAnimation(long duration, long delay, @FloatRange(from = 0.0f, to = 1.0f) float translationPercent) { + private void showContentWithAnimation(long duration, + long delay, + @FloatRange(from = 0.0f, to = 1.0f) float translationPercent) { int translationY = (int) (getResources().getDisplayMetrics().heightPixels * (translationPercent > 0.0f ? translationPercent : .06f)); @@ -945,23 +1021,38 @@ public class VideoDetailFragment extends BaseStateFragment implement contentRootLayoutHiding.setAlpha(0f); contentRootLayoutHiding.setTranslationY(translationY); contentRootLayoutHiding.setVisibility(View.VISIBLE); - contentRootLayoutHiding.animate().alpha(1f).translationY(0) - .setStartDelay(delay).setDuration(duration).setInterpolator(new FastOutSlowInInterpolator()).start(); + contentRootLayoutHiding.animate() + .alpha(1f) + .translationY(0) + .setStartDelay(delay) + .setDuration(duration) + .setInterpolator(new FastOutSlowInInterpolator()) + .start(); uploaderRootLayout.animate().setListener(null).cancel(); uploaderRootLayout.setAlpha(0f); uploaderRootLayout.setTranslationY(translationY); uploaderRootLayout.setVisibility(View.VISIBLE); - uploaderRootLayout.animate().alpha(1f).translationY(0) - .setStartDelay((long) (duration * .5f) + delay).setDuration(duration).setInterpolator(new FastOutSlowInInterpolator()).start(); + uploaderRootLayout.animate() + .alpha(1f) + .translationY(0) + .setStartDelay((long) (duration * .5f) + delay) + .setDuration(duration) + .setInterpolator(new FastOutSlowInInterpolator()) + .start(); if (showRelatedStreams) { relatedStreamRootLayout.animate().setListener(null).cancel(); relatedStreamRootLayout.setAlpha(0f); relatedStreamRootLayout.setTranslationY(translationY); relatedStreamRootLayout.setVisibility(View.VISIBLE); - relatedStreamRootLayout.animate().alpha(1f).translationY(0) - .setStartDelay((long) (duration * .8f) + delay).setDuration(duration).setInterpolator(new FastOutSlowInInterpolator()).start(); + relatedStreamRootLayout.animate() + .alpha(1f) + .translationY(0) + .setStartDelay((long) (duration * .8f) + delay) + .setDuration(duration) + .setInterpolator(new FastOutSlowInInterpolator()) + .start(); } } @@ -975,12 +1066,8 @@ public class VideoDetailFragment extends BaseStateFragment implement if (thumbnailImageView == null || activity == null) return; thumbnailImageView.setImageDrawable(ContextCompat.getDrawable(activity, imageResource)); - animateView(thumbnailImageView, false, 0, 0, new Runnable() { - @Override - public void run() { - animateView(thumbnailImageView, true, 500); - } - }); + animateView(thumbnailImageView, false, 0, 0, + () -> animateView(thumbnailImageView, true, 500)); } @Override @@ -1004,6 +1091,7 @@ public class VideoDetailFragment extends BaseStateFragment implement animateView(contentRootLayoutHiding, false, 200); animateView(spinnerToolbar, false, 200); animateView(thumbnailPlayButton, false, 50); + animateView(detailDurationView, false, 100); videoTitleTextView.setText(name != null ? name : ""); videoTitleTextView.setMaxLines(1); @@ -1033,6 +1121,7 @@ public class VideoDetailFragment extends BaseStateFragment implement if (!TextUtils.isEmpty(info.getUploaderName())) { uploaderTextView.setText(info.getUploaderName()); uploaderTextView.setVisibility(View.VISIBLE); + uploaderTextView.setSelected(true); } else { uploaderTextView.setVisibility(View.GONE); } @@ -1073,6 +1162,18 @@ public class VideoDetailFragment extends BaseStateFragment implement thumbsDisabledTextView.setVisibility(View.GONE); } + if (info.getDuration() > 0) { + detailDurationView.setText(Localization.getDurationString(info.getDuration())); + detailDurationView.setBackgroundColor(ContextCompat.getColor(activity, R.color.duration_background_color)); + animateView(detailDurationView, true, 100); + } else if (info.getStreamType() == StreamType.LIVE_STREAM) { + detailDurationView.setText(R.string.duration_live); + detailDurationView.setBackgroundColor(ContextCompat.getColor(activity, R.color.live_duration_background_color)); + animateView(detailDurationView, true, 100); + } else { + detailDurationView.setVisibility(View.GONE); + } + videoTitleRoot.setClickable(true); videoTitleToggleArrow.setVisibility(View.VISIBLE); videoTitleToggleArrow.setImageResource(R.drawable.arrow_down); @@ -1084,7 +1185,7 @@ public class VideoDetailFragment extends BaseStateFragment implement prepareDescription(info.getDescription()); animateView(spinnerToolbar, true, 500); - setupActionBarHandler(info); + setupActionBar(info); initThumbnailViews(info); initRelatedVideos(info); if (wasRelatedStreamsExpanded) { @@ -1094,14 +1195,28 @@ public class VideoDetailFragment extends BaseStateFragment implement setTitleToUrl(info.getServiceId(), info.getUrl(), info.getName()); if (!info.getErrors().isEmpty()) { - showSnackBarError(info.getErrors(), UserAction.REQUESTED_STREAM, NewPipe.getNameOfService(info.getServiceId()), info.getUrl(), 0); + showSnackBarError(info.getErrors(), + UserAction.REQUESTED_STREAM, + NewPipe.getNameOfService(info.getServiceId()), + info.getUrl(), + 0); } - if (info.video_streams.isEmpty() && info.video_only_streams.isEmpty()) { - detailControlsBackground.setVisibility(View.GONE); - detailControlsPopup.setVisibility(View.GONE); - spinnerToolbar.setVisibility(View.GONE); - thumbnailPlayButton.setImageResource(R.drawable.ic_headset_white_24dp); + switch (info.getStreamType()) { + case LIVE_STREAM: + case AUDIO_LIVE_STREAM: + detailControlsDownload.setVisibility(View.GONE); + spinnerToolbar.setVisibility(View.GONE); + break; + default: + if (!info.getVideoStreams().isEmpty() + || !info.getVideoOnlyStreams().isEmpty()) break; + + detailControlsBackground.setVisibility(View.GONE); + detailControlsPopup.setVisibility(View.GONE); + spinnerToolbar.setVisibility(View.GONE); + thumbnailPlayButton.setImageResource(R.drawable.ic_headset_white_24dp); + break; } if (autoPlayEnabled) { @@ -1121,28 +1236,30 @@ public class VideoDetailFragment extends BaseStateFragment implement if (exception instanceof YoutubeStreamExtractor.GemaException) { onBlockedByGemaError(); - } else if (exception instanceof YoutubeStreamExtractor.LiveStreamException) { - showError(getString(R.string.live_streams_not_supported), false); } else if (exception instanceof ContentNotAvailableException) { showError(getString(R.string.content_not_available), false); } else { - int errorId = exception instanceof YoutubeStreamExtractor.DecryptException ? R.string.youtube_signature_decryption_error : - exception instanceof ParsingException ? R.string.parsing_error : R.string.general_error; - onUnrecoverableError(exception, UserAction.REQUESTED_STREAM, NewPipe.getNameOfService(serviceId), url, errorId); + int errorId = exception instanceof YoutubeStreamExtractor.DecryptException + ? R.string.youtube_signature_decryption_error + : exception instanceof ParsingException + ? R.string.parsing_error + : R.string.general_error; + onUnrecoverableError(exception, + UserAction.REQUESTED_STREAM, + NewPipe.getNameOfService(serviceId), + url, + errorId); } return true; } public void onBlockedByGemaError() { - thumbnailBackgroundButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - Intent intent = new Intent(); - intent.setAction(Intent.ACTION_VIEW); - intent.setData(Uri.parse(getString(R.string.c3s_url))); - startActivity(intent); - } + thumbnailBackgroundButton.setOnClickListener((View v) -> { + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_VIEW); + intent.setData(Uri.parse(getString(R.string.c3s_url))); + startActivity(intent); }); showError(getString(R.string.blocked_by_gema), false, R.drawable.gruese_die_gema); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java index a09a472a5..580e16825 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java @@ -3,19 +3,15 @@ package org.schabi.newpipe.fragments.list; import android.app.Activity; import android.content.Context; import android.content.DialogInterface; -import android.os.Build; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.v7.app.ActionBar; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.util.Log; -import android.view.Gravity; import android.view.Menu; import android.view.MenuInflater; import android.view.View; -import android.widget.TextView; -import android.widget.Toast; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; @@ -24,14 +20,15 @@ import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; -import org.schabi.newpipe.info_list.InfoItemBuilder; +import org.schabi.newpipe.fragments.local.dialog.PlaylistAppendDialog; import org.schabi.newpipe.info_list.InfoItemDialog; import org.schabi.newpipe.info_list.InfoListAdapter; import org.schabi.newpipe.playlist.SinglePlayQueue; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.PermissionHelper; +import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.StateSaver; +import java.util.Collections; import java.util.List; import java.util.Queue; @@ -140,12 +137,11 @@ public abstract class BaseListFragment extends BaseStateFragment implem @Override protected void initListeners() { super.initListeners(); - infoListAdapter.setOnStreamSelectedListener(new InfoItemBuilder.OnInfoItemSelectedListener() { + infoListAdapter.setOnStreamSelectedListener(new OnClickGesture() { @Override public void selected(StreamInfoItem selectedItem) { onItemSelected(selectedItem); - NavigationHelper.openVideoDetailFragment( - useAsFrontPage?getParentFragment().getFragmentManager():getFragmentManager(), + NavigationHelper.openVideoDetailFragment(useAsFrontPage ? getParentFragment().getFragmentManager() : getFragmentManager(), selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName()); } @@ -155,30 +151,22 @@ public abstract class BaseListFragment extends BaseStateFragment implem } }); - infoListAdapter.setOnChannelSelectedListener(new InfoItemBuilder.OnInfoItemSelectedListener() { + infoListAdapter.setOnChannelSelectedListener(new OnClickGesture() { @Override public void selected(ChannelInfoItem selectedItem) { onItemSelected(selectedItem); - NavigationHelper.openChannelFragment( - useAsFrontPage?getParentFragment().getFragmentManager():getFragmentManager(), + NavigationHelper.openChannelFragment(useAsFrontPage ? getParentFragment().getFragmentManager() : getFragmentManager(), selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName()); } - - @Override - public void held(ChannelInfoItem selectedItem) {} }); - infoListAdapter.setOnPlaylistSelectedListener(new InfoItemBuilder.OnInfoItemSelectedListener() { + infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture() { @Override public void selected(PlaylistInfoItem selectedItem) { onItemSelected(selectedItem); - NavigationHelper.openPlaylistFragment( - useAsFrontPage?getParentFragment().getFragmentManager():getFragmentManager(), + NavigationHelper.openPlaylistFragment(useAsFrontPage ? getParentFragment().getFragmentManager() : getFragmentManager(), selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName()); } - - @Override - public void held(PlaylistInfoItem selectedItem) {} }); itemsList.clearOnScrollListeners(); @@ -203,22 +191,26 @@ public abstract class BaseListFragment extends BaseStateFragment implem final String[] commands = new String[]{ context.getResources().getString(R.string.enqueue_on_background), - context.getResources().getString(R.string.enqueue_on_popup) + context.getResources().getString(R.string.enqueue_on_popup), + context.getResources().getString(R.string.append_playlist) }; - final DialogInterface.OnClickListener actions = new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialogInterface, int i) { - switch (i) { - case 0: - NavigationHelper.enqueueOnBackgroundPlayer(context, new SinglePlayQueue(item)); - break; - case 1: - NavigationHelper.enqueueOnPopupPlayer(activity, new SinglePlayQueue(item)); - break; - default: - break; - } + final DialogInterface.OnClickListener actions = (dialogInterface, i) -> { + switch (i) { + case 0: + NavigationHelper.enqueueOnBackgroundPlayer(context, new SinglePlayQueue(item)); + break; + case 1: + NavigationHelper.enqueueOnPopupPlayer(activity, new SinglePlayQueue(item)); + break; + case 2: + if (getFragmentManager() != null) { + PlaylistAppendDialog.fromStreamInfoItems(Collections.singletonList(item)) + .show(getFragmentManager(), TAG); + } + break; + default: + break; } }; @@ -235,7 +227,7 @@ public abstract class BaseListFragment extends BaseStateFragment implem ActionBar supportActionBar = activity.getSupportActionBar(); if (supportActionBar != null) { supportActionBar.setDisplayShowTitleEnabled(true); - if(useAsFrontPage) { + if (useAsFrontPage) { supportActionBar.setDisplayHomeAsUpEnabled(false); } else { supportActionBar.setDisplayHomeAsUpEnabled(true); @@ -282,9 +274,8 @@ public abstract class BaseListFragment extends BaseStateFragment implem @Override public void showListFooter(final boolean show) { - itemsList.post(new Runnable() { - @Override - public void run() { + itemsList.post(() -> { + if (infoListAdapter != null && itemsList != null) { infoListAdapter.showFooter(show); } }); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java index 8a26d81aa..a132213bf 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java @@ -19,7 +19,8 @@ import io.reactivex.disposables.Disposable; import io.reactivex.functions.Consumer; import io.reactivex.schedulers.Schedulers; -public abstract class BaseListInfoFragment extends BaseListFragment { +public abstract class BaseListInfoFragment + extends BaseListFragment { @State protected int serviceId = Constants.NO_SERVICE_ID; @@ -29,7 +30,7 @@ public abstract class BaseListInfoFragment extends BaseListF protected String url; protected I currentInfo; - protected String currentNextItemsUrl; + protected String currentNextPageUrl; protected Disposable currentWorker; @Override @@ -73,7 +74,7 @@ public abstract class BaseListInfoFragment extends BaseListF public void writeTo(Queue objectsToSave) { super.writeTo(objectsToSave); objectsToSave.add(currentInfo); - objectsToSave.add(currentNextItemsUrl); + objectsToSave.add(currentNextPageUrl); } @Override @@ -81,7 +82,7 @@ public abstract class BaseListInfoFragment extends BaseListF public void readFrom(@NonNull Queue savedObjects) throws Exception { super.readFrom(savedObjects); currentInfo = (I) savedObjects.poll(); - currentNextItemsUrl = (String) savedObjects.poll(); + currentNextPageUrl = (String) savedObjects.poll(); } /*////////////////////////////////////////////////////////////////////////// @@ -116,7 +117,7 @@ public abstract class BaseListInfoFragment extends BaseListF .subscribe((@NonNull I result) -> { isLoading.set(false); currentInfo = result; - currentNextItemsUrl = result.next_streams_url; + currentNextPageUrl = result.getNextPageUrl(); handleResult(result); }, (@NonNull Throwable throwable) -> onError(throwable)); } @@ -125,7 +126,7 @@ public abstract class BaseListInfoFragment extends BaseListF * Implement the logic to load more items
* You can use the default implementations from {@link org.schabi.newpipe.util.ExtractorHelper} */ - protected abstract Single loadMoreItemsLogic(); + protected abstract Single loadMoreItemsLogic(); protected void loadMoreItems() { isLoading.set(true); @@ -134,9 +135,9 @@ public abstract class BaseListInfoFragment extends BaseListF currentWorker = loadMoreItemsLogic() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe((@io.reactivex.annotations.NonNull ListExtractor.NextItemsResult nextItemsResult) -> { + .subscribe((@io.reactivex.annotations.NonNull ListExtractor.InfoItemsPage InfoItemsPage) -> { isLoading.set(false); - handleNextItems(nextItemsResult); + handleNextItems(InfoItemsPage); }, (@io.reactivex.annotations.NonNull Throwable throwable) -> { isLoading.set(false); onError(throwable); @@ -144,17 +145,17 @@ public abstract class BaseListInfoFragment extends BaseListF } @Override - public void handleNextItems(ListExtractor.NextItemsResult result) { + public void handleNextItems(ListExtractor.InfoItemsPage result) { super.handleNextItems(result); - currentNextItemsUrl = result.nextItemsUrl; - infoListAdapter.addInfoItemList(result.nextItemsList); + currentNextPageUrl = result.getNextPageUrl(); + infoListAdapter.addInfoItemList(result.getItems()); showListFooter(hasMoreItems()); } @Override protected boolean hasMoreItems() { - return !TextUtils.isEmpty(currentNextItemsUrl); + return !TextUtils.isEmpty(currentNextPageUrl); } /*////////////////////////////////////////////////////////////////////////// @@ -170,8 +171,8 @@ public abstract class BaseListInfoFragment extends BaseListF setTitle(name); if (infoListAdapter.getItemsList().size() == 0) { - if (result.related_streams.size() > 0) { - infoListAdapter.addInfoItemList(result.related_streams); + if (result.getRelatedItems().size() > 0) { + infoListAdapter.addInfoItemList(result.getRelatedItems()); showListFooter(hasMoreItems()); } else { infoListAdapter.clearStreamItemList(); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java index 1b24a5dce..dbc61961e 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java @@ -27,23 +27,28 @@ import com.jakewharton.rxbinding2.view.RxView; import org.schabi.newpipe.R; import org.schabi.newpipe.database.subscription.SubscriptionEntity; +import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.stream.Stream; +import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; -import org.schabi.newpipe.fragments.subscription.SubscriptionService; import org.schabi.newpipe.info_list.InfoItemDialog; import org.schabi.newpipe.playlist.ChannelPlayQueue; import org.schabi.newpipe.playlist.PlayQueue; import org.schabi.newpipe.playlist.SinglePlayQueue; import org.schabi.newpipe.report.UserAction; +import org.schabi.newpipe.subscription.SubscriptionService; import org.schabi.newpipe.util.AnimationUtils; import org.schabi.newpipe.util.ExtractorHelper; +import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; @@ -108,11 +113,11 @@ public class ChannelFragment extends BaseListInfoFragment { @Override public void onAttach(Context context) { super.onAttach(context); - subscriptionService = SubscriptionService.getInstance(); + subscriptionService = SubscriptionService.getInstance(activity); } @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_channel, container, false); } @@ -194,17 +199,14 @@ public class ChannelFragment extends BaseListInfoFragment { public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); ActionBar supportActionBar = activity.getSupportActionBar(); - if(useAsFrontPage) { + if(useAsFrontPage && supportActionBar != null) { supportActionBar.setDisplayHomeAsUpEnabled(false); } else { inflater.inflate(R.menu.menu_channel, menu); - if (DEBUG) Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + "], inflater = [" + inflater + "]"); + if (DEBUG) Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + + "], inflater = [" + inflater + "]"); menuRssButton = menu.findItem(R.id.menu_item_rss); - if (currentInfo != null) { - menuRssButton.setVisible(!TextUtils.isEmpty(currentInfo.getFeedUrl())); - } - } } @@ -225,10 +227,9 @@ public class ChannelFragment extends BaseListInfoFragment { case R.id.menu_item_openInBrowser: openUrlInBrowser(url); break; - case R.id.menu_item_share: { + case R.id.menu_item_share: shareUrl(name, url); break; - } default: return super.onOptionsItemSelected(item); } @@ -392,8 +393,8 @@ public class ChannelFragment extends BaseListInfoFragment { //////////////////////////////////////////////////////////////////////////*/ @Override - protected Single loadMoreItemsLogic() { - return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextItemsUrl); + protected Single loadMoreItemsLogic() { + return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPageUrl); } @Override @@ -419,8 +420,10 @@ public class ChannelFragment extends BaseListInfoFragment { super.handleResult(result); headerRootLayout.setVisibility(View.VISIBLE); - imageLoader.displayImage(result.banner_url, headerChannelBanner, DISPLAY_BANNER_OPTIONS); - imageLoader.displayImage(result.avatar_url, headerAvatarView, DISPLAY_AVATAR_OPTIONS); + imageLoader.displayImage(result.getBannerUrl(), headerChannelBanner, + ImageDisplayConstants.DISPLAY_BANNER_OPTIONS); + imageLoader.displayImage(result.getAvatarUrl(), headerAvatarView, + ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS); if (result.getSubscriberCount() != -1) { headerSubscribersTextView.setText(Localization.localizeSubscribersCount(activity, result.getSubscriberCount())); @@ -428,10 +431,11 @@ public class ChannelFragment extends BaseListInfoFragment { } else headerSubscribersTextView.setVisibility(View.GONE); if (menuRssButton != null) menuRssButton.setVisible(!TextUtils.isEmpty(result.getFeedUrl())); + playlistCtrl.setVisibility(View.VISIBLE); - if (!result.errors.isEmpty()) { - showSnackBarError(result.errors, UserAction.REQUESTED_CHANNEL, NewPipe.getNameOfService(result.getServiceId()), result.getUrl(), 0); + if (!result.getErrors().isEmpty()) { + showSnackBarError(result.getErrors(), UserAction.REQUESTED_CHANNEL, NewPipe.getNameOfService(result.getServiceId()), result.getUrl(), 0); } if (disposables != null) disposables.clear(); @@ -439,24 +443,12 @@ public class ChannelFragment extends BaseListInfoFragment { updateSubscription(result); monitorSubscription(result); - headerPlayAllButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - NavigationHelper.playOnMainPlayer(activity, getPlayQueue()); - } - }); - headerPopupButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - NavigationHelper.playOnPopupPlayer(activity, getPlayQueue()); - } - }); - headerBackgroundButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue()); - } - }); + headerPlayAllButton.setOnClickListener( + view -> NavigationHelper.playOnMainPlayer(activity, getPlayQueue())); + headerPopupButton.setOnClickListener( + view -> NavigationHelper.playOnPopupPlayer(activity, getPlayQueue())); + headerBackgroundButton.setOnClickListener( + view -> NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue())); } private PlayQueue getPlayQueue() { @@ -464,17 +456,23 @@ public class ChannelFragment extends BaseListInfoFragment { } private PlayQueue getPlayQueue(final int index) { + final List streamItems = new ArrayList<>(); + for(InfoItem i : infoListAdapter.getItemsList()) { + if(i instanceof StreamInfoItem) { + streamItems.add((StreamInfoItem) i); + } + } return new ChannelPlayQueue( currentInfo.getServiceId(), currentInfo.getUrl(), - currentInfo.getNextStreamsUrl(), - infoListAdapter.getItemsList(), + currentInfo.getNextPageUrl(), + streamItems, index ); } @Override - public void handleNextItems(ListExtractor.NextItemsResult result) { + public void handleNextItems(ListExtractor.InfoItemsPage result) { super.handleNextItems(result); if (!result.getErrors().isEmpty()) { diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/feed/FeedFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/feed/FeedFragment.java index a62593047..dabfd9e1b 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/feed/FeedFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/feed/FeedFragment.java @@ -21,8 +21,8 @@ import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.fragments.list.BaseListFragment; -import org.schabi.newpipe.fragments.subscription.SubscriptionService; import org.schabi.newpipe.report.UserAction; +import org.schabi.newpipe.subscription.SubscriptionService; import java.util.Collections; import java.util.HashSet; @@ -64,7 +64,7 @@ public class FeedFragment extends BaseListFragment, Voi @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - subscriptionService = SubscriptionService.getInstance(); + subscriptionService = SubscriptionService.getInstance(activity); FEED_LOAD_COUNT = howManyItemsToLoad(); } @@ -297,12 +297,12 @@ public class FeedFragment extends BaseListFragment, Voi // Called only when response is non-empty @Override public void onSuccess(final ChannelInfo channelInfo) { - if (infoListAdapter == null || channelInfo.getRelatedStreams().isEmpty()) { + if (infoListAdapter == null || channelInfo.getRelatedItems().isEmpty()) { onDone(); return; } - final InfoItem item = channelInfo.getRelatedStreams().get(0); + final InfoItem item = channelInfo.getRelatedItems().get(0); // Keep requesting new items if the current one already exists boolean itemExists = doesItemExist(infoListAdapter.getItemsList(), item); if (!itemExists) { @@ -411,7 +411,7 @@ public class FeedFragment extends BaseListFragment, Voi private boolean doesItemExist(final List items, final InfoItem item) { for (final InfoItem existingItem : items) { - if (existingItem.info_type == item.info_type && + if (existingItem.getInfoType() == item.getInfoType() && existingItem.getServiceId() == item.getServiceId() && existingItem.getName().equals(item.getName()) && existingItem.getUrl().equals(item.getUrl())) return true; diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java index 830471b73..482f71cb4 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java @@ -141,12 +141,12 @@ public class KioskFragment extends BaseListInfoFragment { } @Override - public Single loadMoreItemsLogic() { + public Single loadMoreItemsLogic() { String contentCountry = PreferenceManager .getDefaultSharedPreferences(activity) .getString(getString(R.string.content_country_key), getString(R.string.default_country_value)); - return ExtractorHelper.getMoreKioskItems(serviceId, url, currentNextItemsUrl, contentCountry); + return ExtractorHelper.getMoreKioskItems(serviceId, url, currentNextPageUrl, contentCountry); } /*////////////////////////////////////////////////////////////////////////// @@ -174,7 +174,7 @@ public class KioskFragment extends BaseListInfoFragment { } @Override - public void handleNextItems(ListExtractor.NextItemsResult result) { + public void handleNextItems(ListExtractor.InfoItemsPage result) { super.handleNextItems(result); if (!result.getErrors().isEmpty()) { diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java index 52eeb337c..3bcf9d322 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java @@ -17,27 +17,50 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; +import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; +import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; +import org.schabi.newpipe.fragments.local.RemotePlaylistManager; import org.schabi.newpipe.info_list.InfoItemDialog; import org.schabi.newpipe.playlist.PlayQueue; import org.schabi.newpipe.playlist.PlaylistPlayQueue; import org.schabi.newpipe.playlist.SinglePlayQueue; import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.util.ExtractorHelper; +import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.ThemeHelper; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; +import io.reactivex.disposables.Disposables; import static org.schabi.newpipe.util.AnimationUtils.animateView; public class PlaylistFragment extends BaseListInfoFragment { + private CompositeDisposable disposables; + private Subscription bookmarkReactor; + private AtomicBoolean isBookmarkButtonReady; + + private RemotePlaylistManager remotePlaylistManager; + private PlaylistRemoteEntity playlistEntity; /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ @@ -54,6 +77,8 @@ public class PlaylistFragment extends BaseListInfoFragment { private View headerPopupButton; private View headerBackgroundButton; + private MenuItem playlistBookmarkButton; + public static PlaylistFragment getInstance(int serviceId, String url, String name) { PlaylistFragment instance = new PlaylistFragment(); instance.setInitialData(serviceId, url, name); @@ -65,7 +90,16 @@ public class PlaylistFragment extends BaseListInfoFragment { //////////////////////////////////////////////////////////////////////////*/ @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + disposables = new CompositeDisposable(); + isBookmarkButtonReady = new AtomicBoolean(false); + remotePlaylistManager = new RemotePlaylistManager(NewPipeDatabase.getInstance(getContext())); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_playlist, container, false); } @@ -86,6 +120,7 @@ public class PlaylistFragment extends BaseListInfoFragment { headerPopupButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_popup_button); headerBackgroundButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_bg_button); + return headerRootLayout; } @@ -110,29 +145,26 @@ public class PlaylistFragment extends BaseListInfoFragment { context.getResources().getString(R.string.start_here_on_popup), }; - final DialogInterface.OnClickListener actions = new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialogInterface, int i) { - final int index = Math.max(infoListAdapter.getItemsList().indexOf(item), 0); - switch (i) { - case 0: - NavigationHelper.enqueueOnBackgroundPlayer(context, new SinglePlayQueue(item)); - break; - case 1: - NavigationHelper.enqueueOnPopupPlayer(activity, new SinglePlayQueue(item)); - break; - case 2: - NavigationHelper.playOnMainPlayer(context, getPlayQueue(index)); - break; - case 3: - NavigationHelper.playOnBackgroundPlayer(context, getPlayQueue(index)); - break; - case 4: - NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(index)); - break; - default: - break; - } + final DialogInterface.OnClickListener actions = (dialogInterface, i) -> { + final int index = Math.max(infoListAdapter.getItemsList().indexOf(item), 0); + switch (i) { + case 0: + NavigationHelper.enqueueOnBackgroundPlayer(context, new SinglePlayQueue(item)); + break; + case 1: + NavigationHelper.enqueueOnPopupPlayer(activity, new SinglePlayQueue(item)); + break; + case 2: + NavigationHelper.playOnMainPlayer(context, getPlayQueue(index)); + break; + case 3: + NavigationHelper.playOnBackgroundPlayer(context, getPlayQueue(index)); + break; + case 4: + NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(index)); + break; + default: + break; } }; @@ -141,9 +173,36 @@ public class PlaylistFragment extends BaseListInfoFragment { @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { - if (DEBUG) Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + "], inflater = [" + inflater + "]"); + if (DEBUG) Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + + "], inflater = [" + inflater + "]"); super.onCreateOptionsMenu(menu, inflater); inflater.inflate(R.menu.menu_playlist, menu); + + playlistBookmarkButton = menu.findItem(R.id.menu_item_bookmark); + updateBookmarkButtons(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + if (isBookmarkButtonReady != null) isBookmarkButtonReady.set(false); + + if (disposables != null) disposables.clear(); + if (bookmarkReactor != null) bookmarkReactor.cancel(); + + bookmarkReactor = null; + } + + @Override + public void onDestroy() { + super.onDestroy(); + + if (disposables != null) disposables.dispose(); + + disposables = null; + remotePlaylistManager = null; + playlistEntity = null; + isBookmarkButtonReady = null; } /*////////////////////////////////////////////////////////////////////////// @@ -151,8 +210,8 @@ public class PlaylistFragment extends BaseListInfoFragment { //////////////////////////////////////////////////////////////////////////*/ @Override - protected Single loadMoreItemsLogic() { - return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextItemsUrl); + protected Single loadMoreItemsLogic() { + return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPageUrl); } @Override @@ -166,10 +225,12 @@ public class PlaylistFragment extends BaseListInfoFragment { case R.id.menu_item_openInBrowser: openUrlInBrowser(url); break; - case R.id.menu_item_share: { + case R.id.menu_item_share: shareUrl(name, url); break; - } + case R.id.menu_item_bookmark: + onBookmarkClicked(); + break; default: return super.onOptionsItemSelected(item); } @@ -201,42 +262,40 @@ public class PlaylistFragment extends BaseListInfoFragment { if (!TextUtils.isEmpty(result.getUploaderName())) { headerUploaderName.setText(result.getUploaderName()); if (!TextUtils.isEmpty(result.getUploaderUrl())) { - headerUploaderLayout.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - NavigationHelper.openChannelFragment(getFragmentManager(), result.getServiceId(), result.getUploaderUrl(), result.getUploaderName()); - } - }); + headerUploaderLayout.setOnClickListener(v -> + NavigationHelper.openChannelFragment(getFragmentManager(), + result.getServiceId(), result.getUploaderUrl(), + result.getUploaderName()) + ); } } playlistCtrl.setVisibility(View.VISIBLE); - imageLoader.displayImage(result.getUploaderAvatarUrl(), headerUploaderAvatar, DISPLAY_AVATAR_OPTIONS); - headerStreamCount.setText(getResources().getQuantityString(R.plurals.videos, (int) result.stream_count, (int) result.stream_count)); + imageLoader.displayImage(result.getUploaderAvatarUrl(), headerUploaderAvatar, + ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS); + headerStreamCount.setText(getResources().getQuantityString(R.plurals.videos, + (int) result.getStreamCount(), (int) result.getStreamCount())); if (!result.getErrors().isEmpty()) { showSnackBarError(result.getErrors(), UserAction.REQUESTED_PLAYLIST, NewPipe.getNameOfService(result.getServiceId()), result.getUrl(), 0); } - headerPlayAllButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - NavigationHelper.playOnMainPlayer(activity, getPlayQueue()); - } - }); - headerPopupButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - NavigationHelper.playOnPopupPlayer(activity, getPlayQueue()); - } - }); - headerBackgroundButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue()); - } - }); + remotePlaylistManager.getPlaylist(result) + .onBackpressureLatest() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getPlaylistBookmarkSubscriber()); + + remotePlaylistManager.onUpdate(result) + .subscribeOn(AndroidSchedulers.mainThread()) + .subscribe(integer -> {/* Do nothing*/}, this::onError); + + headerPlayAllButton.setOnClickListener(view -> + NavigationHelper.playOnMainPlayer(activity, getPlayQueue())); + headerPopupButton.setOnClickListener(view -> + NavigationHelper.playOnPopupPlayer(activity, getPlayQueue())); + headerBackgroundButton.setOnClickListener(view -> + NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue())); } private PlayQueue getPlayQueue() { @@ -244,17 +303,23 @@ public class PlaylistFragment extends BaseListInfoFragment { } private PlayQueue getPlayQueue(final int index) { + final List infoItems = new ArrayList<>(); + for(InfoItem i : infoListAdapter.getItemsList()) { + if(i instanceof StreamInfoItem) { + infoItems.add((StreamInfoItem) i); + } + } return new PlaylistPlayQueue( currentInfo.getServiceId(), currentInfo.getUrl(), - currentInfo.getNextStreamsUrl(), - infoListAdapter.getItemsList(), + currentInfo.getNextPageUrl(), + infoItems, index ); } @Override - public void handleNextItems(ListExtractor.NextItemsResult result) { + public void handleNextItems(ListExtractor.InfoItemsPage result) { super.handleNextItems(result); if (!result.getErrors().isEmpty()) { @@ -280,9 +345,76 @@ public class PlaylistFragment extends BaseListInfoFragment { // Utils //////////////////////////////////////////////////////////////////////////*/ + private Subscriber> getPlaylistBookmarkSubscriber() { + return new Subscriber>() { + @Override + public void onSubscribe(Subscription s) { + if (bookmarkReactor != null) bookmarkReactor.cancel(); + bookmarkReactor = s; + bookmarkReactor.request(1); + } + + @Override + public void onNext(List playlist) { + playlistEntity = playlist.isEmpty() ? null : playlist.get(0); + + updateBookmarkButtons(); + isBookmarkButtonReady.set(true); + + if (bookmarkReactor != null) bookmarkReactor.request(1); + } + + @Override + public void onError(Throwable t) { + PlaylistFragment.this.onError(t); + } + + @Override + public void onComplete() { + + } + }; + } + @Override public void setTitle(String title) { super.setTitle(title); headerTitleView.setText(title); } + + private void onBookmarkClicked() { + if (isBookmarkButtonReady == null || !isBookmarkButtonReady.get() || + remotePlaylistManager == null) + return; + + final Disposable action; + + if (currentInfo != null && playlistEntity == null) { + action = remotePlaylistManager.onBookmark(currentInfo) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignored -> {/* Do nothing */}, this::onError); + } else if (playlistEntity != null) { + action = remotePlaylistManager.deletePlaylist(playlistEntity.getUid()) + .observeOn(AndroidSchedulers.mainThread()) + .doFinally(() -> playlistEntity = null) + .subscribe(ignored -> {/* Do nothing */}, this::onError); + } else { + action = Disposables.empty(); + } + + disposables.add(action); + } + + private void updateBookmarkButtons() { + if (playlistBookmarkButton == null || activity == null) return; + + final int iconAttr = playlistEntity == null ? + R.attr.ic_playlist_add : R.attr.ic_playlist_check; + + final int titleRes = playlistEntity == null ? + R.string.bookmark_playlist : R.string.unbookmark_playlist; + + playlistBookmarkButton.setIcon(ThemeHelper.resolveResourceIdFromAttr(activity, iconAttr)); + playlistBookmarkButton.setTitle(titleRes); + } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java index d6ed2a313..f7831e02d 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java @@ -2,7 +2,6 @@ package org.schabi.newpipe.fragments.list.search; import android.app.Activity; import android.content.Context; -import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; @@ -30,10 +29,8 @@ import android.view.inputmethod.InputMethodManager; import android.widget.EditText; import android.widget.TextView; -import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.ReCaptchaActivity; -import org.schabi.newpipe.database.history.dao.SearchHistoryDAO; import org.schabi.newpipe.database.history.model.SearchHistoryEntry; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; @@ -44,7 +41,7 @@ import org.schabi.newpipe.extractor.search.SearchEngine; import org.schabi.newpipe.extractor.search.SearchResult; import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.list.BaseListFragment; -import org.schabi.newpipe.history.HistoryListener; +import org.schabi.newpipe.history.HistoryRecordManager; import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.AnimationUtils; @@ -64,22 +61,19 @@ import java.util.concurrent.TimeUnit; import icepick.State; import io.reactivex.Flowable; -import io.reactivex.Notification; import io.reactivex.Observable; -import io.reactivex.ObservableSource; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.Disposable; -import io.reactivex.functions.BiFunction; import io.reactivex.functions.Consumer; -import io.reactivex.functions.Function; -import io.reactivex.functions.Predicate; import io.reactivex.schedulers.Schedulers; import io.reactivex.subjects.PublishSubject; import static org.schabi.newpipe.util.AnimationUtils.animateView; -public class SearchFragment extends BaseListFragment implements BackPressable { +public class SearchFragment + extends BaseListFragment + implements BackPressable { /*////////////////////////////////////////////////////////////////////////// // Search @@ -121,7 +115,7 @@ public class SearchFragment extends BaseListFragment() { - @Override - public Integer call() throws Exception { - return searchHistoryDAO.deleteAllWhereQuery(item.query); - } - }) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Consumer() { - @Override - public void accept(Integer howManyDeleted) throws Exception { - suggestionPublisher.onNext(searchEditText.getText().toString()); - } - }, new Consumer() { - @Override - public void accept(Throwable throwable) throws Exception { - showSnackBarError(throwable, UserAction.SOMETHING_ELSE, "none", "Deleting item failed", R.string.general_error); - } - })); - } - }).show(); + .setPositiveButton(R.string.delete, (dialog, which) -> { + final Disposable onDelete = historyRecordManager.deleteSearchHistory(query) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + howManyDeleted -> suggestionPublisher + .onNext(searchEditText.getText().toString()), + throwable -> showSnackBarError(throwable, + UserAction.SOMETHING_ELSE, "none", + "Deleting item failed", R.string.general_error) + ); + disposables.add(onDelete); + }) + .show(); } @Override @@ -589,83 +574,67 @@ public class SearchFragment extends BaseListFragment observable = suggestionPublisher .debounce(SUGGESTIONS_DEBOUNCE, TimeUnit.MILLISECONDS) .startWith(searchQuery != null ? searchQuery : "") - .filter(new Predicate() { - @Override - public boolean test(@io.reactivex.annotations.NonNull String query) throws Exception { - return isSuggestionsEnabled; - } - }); + .filter(query -> isSuggestionsEnabled); suggestionDisposable = observable - .switchMap(new Function>>>() { - @Override - public ObservableSource>> apply(@io.reactivex.annotations.NonNull final String query) throws Exception { - final Flowable> flowable = query.length() > 0 - ? searchHistoryDAO.getSimilarEntries(query, 3) - : searchHistoryDAO.getUniqueEntries(25); - final Observable> local = flowable.toObservable() - .map(new Function, List>() { - @Override - public List apply(@io.reactivex.annotations.NonNull List searchHistoryEntries) throws Exception { - List result = new ArrayList<>(); - for (SearchHistoryEntry entry : searchHistoryEntries) - result.add(new SuggestionItem(true, entry.getSearch())); - return result; - } - }); + .switchMap(query -> { + final Flowable> flowable = historyRecordManager + .getRelatedSearches(query, 3, 25); + final Observable> local = flowable.toObservable() + .map(searchHistoryEntries -> { + List result = new ArrayList<>(); + for (SearchHistoryEntry entry : searchHistoryEntries) + result.add(new SuggestionItem(true, entry.getSearch())); + return result; + }); - if (query.length() < THRESHOLD_NETWORK_SUGGESTION) { - // Only pass through if the query length is equal or greater than THRESHOLD_NETWORK_SUGGESTION - return local.materialize(); + if (query.length() < THRESHOLD_NETWORK_SUGGESTION) { + // Only pass through if the query length is equal or greater than THRESHOLD_NETWORK_SUGGESTION + return local.materialize(); + } + + final Observable> network = ExtractorHelper + .suggestionsFor(serviceId, query, contentCountry) + .toObservable() + .map(strings -> { + List result = new ArrayList<>(); + for (String entry : strings) { + result.add(new SuggestionItem(false, entry)); + } + return result; + }); + + return Observable.zip(local, network, (localResult, networkResult) -> { + List result = new ArrayList<>(); + if (localResult.size() > 0) result.addAll(localResult); + + // Remove duplicates + final Iterator iterator = networkResult.iterator(); + while (iterator.hasNext() && localResult.size() > 0) { + final SuggestionItem next = iterator.next(); + for (SuggestionItem item : localResult) { + if (item.query.equals(next.query)) { + iterator.remove(); + break; + } + } } - final Observable> network = ExtractorHelper.suggestionsFor(serviceId, query, contentCountry).toObservable() - .map(new Function, List>() { - @Override - public List apply(@io.reactivex.annotations.NonNull List strings) throws Exception { - List result = new ArrayList<>(); - for (String entry : strings) result.add(new SuggestionItem(false, entry)); - return result; - } - }); - - return Observable.zip(local, network, new BiFunction, List, List>() { - @Override - public List apply(@io.reactivex.annotations.NonNull List localResult, @io.reactivex.annotations.NonNull List networkResult) throws Exception { - List result = new ArrayList<>(); - if (localResult.size() > 0) result.addAll(localResult); - - // Remove duplicates - final Iterator iterator = networkResult.iterator(); - while (iterator.hasNext() && localResult.size() > 0) { - final SuggestionItem next = iterator.next(); - for (SuggestionItem item : localResult) { - if (item.query.equals(next.query)) { - iterator.remove(); - break; - } - } - } - - if (networkResult.size() > 0) result.addAll(networkResult); - return result; - } - }).materialize(); - } + if (networkResult.size() > 0) result.addAll(networkResult); + return result; + }).materialize(); }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Consumer>>() { - @Override - public void accept(@io.reactivex.annotations.NonNull Notification> listNotification) throws Exception { - if (listNotification.isOnNext()) { - handleSuggestions(listNotification.getValue()); - } else if (listNotification.isOnError()) { - Throwable error = listNotification.getError(); - if (!ExtractorHelper.hasAssignableCauseThrowable(error, - IOException.class, SocketException.class, InterruptedException.class, InterruptedIOException.class)) { - onSuggestionError(error); - } + .subscribe(listNotification -> { + if (listNotification.isOnNext()) { + handleSuggestions(listNotification.getValue()); + } else if (listNotification.isOnError()) { + Throwable error = listNotification.getError(); + if (!ExtractorHelper.hasAssignableCauseThrowable(error, + IOException.class, SocketException.class, + InterruptedException.class, InterruptedIOException.class)) { + onSuggestionError(error); } } }); @@ -718,11 +687,14 @@ public class SearchFragment extends BaseListFragment {}, + error -> showSnackBarError(error, UserAction.SEARCHED, + NewPipe.getNameOfService(serviceId), query, 0) + ); + suggestionPublisher.onNext(query); startLoading(false); } @@ -734,19 +706,8 @@ public class SearchFragment extends BaseListFragment() { - @Override - public void accept(@NonNull SearchResult result) throws Exception { - isLoading.set(false); - handleResult(result); - } - }, new Consumer() { - @Override - public void accept(@NonNull Throwable throwable) throws Exception { - isLoading.set(false); - onError(throwable); - } - }); + .doOnEvent((searchResult, throwable) -> isLoading.set(false)) + .subscribe(this::handleResult, this::onError); } @Override @@ -758,19 +719,8 @@ public class SearchFragment extends BaseListFragment() { - @Override - public void accept(@NonNull ListExtractor.NextItemsResult result) throws Exception { - isLoading.set(false); - handleNextItems(result); - } - }, new Consumer() { - @Override - public void accept(@NonNull Throwable throwable) throws Exception { - isLoading.set(false); - onError(throwable); - } - }); + .doOnEvent((nextItemsResult, throwable) -> isLoading.set(false)) + .subscribe(this::handleNextItems, this::onError); } @Override @@ -811,12 +761,7 @@ public class SearchFragment extends BaseListFragment suggestions) { if (DEBUG) Log.d(TAG, "handleSuggestions() called with: suggestions = [" + suggestions + "]"); suggestionsRecyclerView.smoothScrollToPosition(0); - suggestionsRecyclerView.post(new Runnable() { - @Override - public void run() { - suggestionListAdapter.setItems(suggestions); - } - }); + suggestionsRecyclerView.post(() -> suggestionListAdapter.setItems(suggestions)); if (errorPanelRoot.getVisibility() == View.VISIBLE) { hideLoading(); @@ -874,10 +819,10 @@ public class SearchFragment extends BaseListFragment + * Copyright (C) Christian Schabesberger 2016 + * InfoItemBuilder.java is part of NewPipe. + *

+ * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *

+ * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ + +public class LocalItemBuilder { + private static final String TAG = LocalItemBuilder.class.toString(); + + private final Context context; + private ImageLoader imageLoader = ImageLoader.getInstance(); + + private OnClickGesture onSelectedListener; + + public LocalItemBuilder(Context context) { + this.context = context; + } + + public Context getContext() { + return context; + } + + public void displayImage(final String url, final ImageView view, + final DisplayImageOptions options) { + imageLoader.displayImage(url, view, options); + } + + public OnClickGesture getOnItemSelectedListener() { + return onSelectedListener; + } + + public void setOnItemSelectedListener(OnClickGesture listener) { + this.onSelectedListener = listener; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/LocalItemListAdapter.java b/app/src/main/java/org/schabi/newpipe/fragments/local/LocalItemListAdapter.java new file mode 100644 index 000000000..d36f56733 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/LocalItemListAdapter.java @@ -0,0 +1,247 @@ +package org.schabi.newpipe.fragments.local; + +import android.app.Activity; +import android.support.v7.widget.RecyclerView; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; + +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.fragments.local.holder.LocalItemHolder; +import org.schabi.newpipe.fragments.local.holder.LocalPlaylistItemHolder; +import org.schabi.newpipe.fragments.local.holder.LocalPlaylistStreamItemHolder; +import org.schabi.newpipe.fragments.local.holder.LocalStatisticStreamItemHolder; +import org.schabi.newpipe.fragments.local.holder.RemotePlaylistItemHolder; +import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.OnClickGesture; + +import java.text.DateFormat; +import java.util.ArrayList; +import java.util.List; + +/* + * Created by Christian Schabesberger on 01.08.16. + * + * Copyright (C) Christian Schabesberger 2016 + * InfoListAdapter.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ + +public class LocalItemListAdapter extends RecyclerView.Adapter { + + private static final String TAG = LocalItemListAdapter.class.getSimpleName(); + private static final boolean DEBUG = false; + + private static final int HEADER_TYPE = 0; + private static final int FOOTER_TYPE = 1; + + private static final int STREAM_STATISTICS_HOLDER_TYPE = 0x1000; + private static final int STREAM_PLAYLIST_HOLDER_TYPE = 0x1001; + private static final int LOCAL_PLAYLIST_HOLDER_TYPE = 0x2000; + private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x2001; + + private final LocalItemBuilder localItemBuilder; + private final ArrayList localItems; + private final DateFormat dateFormat; + + private boolean showFooter = false; + private View header = null; + private View footer = null; + + public LocalItemListAdapter(Activity activity) { + localItemBuilder = new LocalItemBuilder(activity); + localItems = new ArrayList<>(); + dateFormat = DateFormat.getDateInstance(DateFormat.SHORT, + Localization.getPreferredLocale(activity)); + } + + public void setSelectedListener(OnClickGesture listener) { + localItemBuilder.setOnItemSelectedListener(listener); + } + + public void unsetSelectedListener() { + localItemBuilder.setOnItemSelectedListener(null); + } + + public void addItems(List data) { + if (data != null) { + if (DEBUG) { + Log.d(TAG, "addItems() before > localItems.size() = " + + localItems.size() + ", data.size() = " + data.size()); + } + + int offsetStart = sizeConsideringHeader(); + localItems.addAll(data); + + if (DEBUG) { + Log.d(TAG, "addItems() after > offsetStart = " + offsetStart + + ", localItems.size() = " + localItems.size() + + ", header = " + header + ", footer = " + footer + + ", showFooter = " + showFooter); + } + + notifyItemRangeInserted(offsetStart, data.size()); + + if (footer != null && showFooter) { + int footerNow = sizeConsideringHeader(); + notifyItemMoved(offsetStart, footerNow); + + if (DEBUG) Log.d(TAG, "addItems() footer from " + offsetStart + + " to " + footerNow); + } + } + } + + public void removeItem(final LocalItem data) { + final int index = localItems.indexOf(data); + + localItems.remove(index); + notifyItemRemoved(index + (header != null ? 1 : 0)); + } + + public boolean swapItems(int fromAdapterPosition, int toAdapterPosition) { + final int actualFrom = adapterOffsetWithoutHeader(fromAdapterPosition); + final int actualTo = adapterOffsetWithoutHeader(toAdapterPosition); + + if (actualFrom < 0 || actualTo < 0) return false; + if (actualFrom >= localItems.size() || actualTo >= localItems.size()) return false; + + localItems.add(actualTo, localItems.remove(actualFrom)); + notifyItemMoved(fromAdapterPosition, toAdapterPosition); + return true; + } + + public void clearStreamItemList() { + if (localItems.isEmpty()) { + return; + } + localItems.clear(); + notifyDataSetChanged(); + } + + public void setHeader(View header) { + boolean changed = header != this.header; + this.header = header; + if (changed) notifyDataSetChanged(); + } + + public void setFooter(View view) { + this.footer = view; + } + + public void showFooter(boolean show) { + if (DEBUG) Log.d(TAG, "showFooter() called with: show = [" + show + "]"); + if (show == showFooter) return; + + showFooter = show; + if (show) notifyItemInserted(sizeConsideringHeader()); + else notifyItemRemoved(sizeConsideringHeader()); + } + + private int adapterOffsetWithoutHeader(final int offset) { + return offset - (header != null ? 1 : 0); + } + + private int sizeConsideringHeader() { + return localItems.size() + (header != null ? 1 : 0); + } + + public ArrayList getItemsList() { + return localItems; + } + + @Override + public int getItemCount() { + int count = localItems.size(); + if (header != null) count++; + if (footer != null && showFooter) count++; + + if (DEBUG) { + Log.d(TAG, "getItemCount() called, count = " + count + + ", localItems.size() = " + localItems.size() + + ", header = " + header + ", footer = " + footer + + ", showFooter = " + showFooter); + } + return count; + } + + @Override + public int getItemViewType(int position) { + if (DEBUG) Log.d(TAG, "getItemViewType() called with: position = [" + position + "]"); + + if (header != null && position == 0) { + return HEADER_TYPE; + } else if (header != null) { + position--; + } + if (footer != null && position == localItems.size() && showFooter) { + return FOOTER_TYPE; + } + final LocalItem item = localItems.get(position); + + switch (item.getLocalItemType()) { + case PLAYLIST_LOCAL_ITEM: return LOCAL_PLAYLIST_HOLDER_TYPE; + case PLAYLIST_REMOTE_ITEM: return REMOTE_PLAYLIST_HOLDER_TYPE; + + case PLAYLIST_STREAM_ITEM: return STREAM_PLAYLIST_HOLDER_TYPE; + case STATISTIC_STREAM_ITEM: return STREAM_STATISTICS_HOLDER_TYPE; + default: + Log.e(TAG, "No holder type has been considered for item: [" + + item.getLocalItemType() + "]"); + return -1; + } + } + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int type) { + if (DEBUG) Log.d(TAG, "onCreateViewHolder() called with: parent = [" + + parent + "], type = [" + type + "]"); + switch (type) { + case HEADER_TYPE: + return new HeaderFooterHolder(header); + case FOOTER_TYPE: + return new HeaderFooterHolder(footer); + case LOCAL_PLAYLIST_HOLDER_TYPE: + return new LocalPlaylistItemHolder(localItemBuilder, parent); + case REMOTE_PLAYLIST_HOLDER_TYPE: + return new RemotePlaylistItemHolder(localItemBuilder, parent); + case STREAM_PLAYLIST_HOLDER_TYPE: + return new LocalPlaylistStreamItemHolder(localItemBuilder, parent); + case STREAM_STATISTICS_HOLDER_TYPE: + return new LocalStatisticStreamItemHolder(localItemBuilder, parent); + default: + Log.e(TAG, "No view type has been considered for holder: [" + type + "]"); + return null; + } + } + + @Override + public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { + if (DEBUG) Log.d(TAG, "onBindViewHolder() called with: holder = [" + + holder.getClass().getSimpleName() + "], position = [" + position + "]"); + + if (holder instanceof LocalItemHolder) { + // If header isn't null, offset the items by -1 + if (header != null) position--; + + ((LocalItemHolder) holder).updateFromItem(localItems.get(position), dateFormat); + } else if (holder instanceof HeaderFooterHolder && position == 0 && header != null) { + ((HeaderFooterHolder) holder).view = header; + } else if (holder instanceof HeaderFooterHolder && position == sizeConsideringHeader() + && footer != null && showFooter) { + ((HeaderFooterHolder) holder).view = footer; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/LocalPlaylistManager.java b/app/src/main/java/org/schabi/newpipe/fragments/local/LocalPlaylistManager.java new file mode 100644 index 000000000..c266f5365 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/LocalPlaylistManager.java @@ -0,0 +1,120 @@ +package org.schabi.newpipe.fragments.local; + +import android.support.annotation.Nullable; + +import org.schabi.newpipe.database.AppDatabase; +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; +import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; +import org.schabi.newpipe.database.playlist.dao.PlaylistDAO; +import org.schabi.newpipe.database.playlist.dao.PlaylistStreamDAO; +import org.schabi.newpipe.database.playlist.model.PlaylistEntity; +import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; +import org.schabi.newpipe.database.stream.dao.StreamDAO; +import org.schabi.newpipe.database.stream.model.StreamEntity; + +import java.util.ArrayList; +import java.util.List; + +import io.reactivex.Completable; +import io.reactivex.Flowable; +import io.reactivex.Maybe; +import io.reactivex.Single; +import io.reactivex.schedulers.Schedulers; + +public class LocalPlaylistManager { + + private final AppDatabase database; + private final StreamDAO streamTable; + private final PlaylistDAO playlistTable; + private final PlaylistStreamDAO playlistStreamTable; + + public LocalPlaylistManager(final AppDatabase db) { + database = db; + streamTable = db.streamDAO(); + playlistTable = db.playlistDAO(); + playlistStreamTable = db.playlistStreamDAO(); + } + + public Maybe> createPlaylist(final String name, final List streams) { + // Disallow creation of empty playlists + if (streams.isEmpty()) return Maybe.empty(); + final StreamEntity defaultStream = streams.get(0); + final PlaylistEntity newPlaylist = + new PlaylistEntity(name, defaultStream.getThumbnailUrl()); + + return Maybe.fromCallable(() -> database.runInTransaction(() -> + upsertStreams(playlistTable.insert(newPlaylist), streams, 0)) + ).subscribeOn(Schedulers.io()); + } + + public Maybe> appendToPlaylist(final long playlistId, + final List streams) { + return playlistStreamTable.getMaximumIndexOf(playlistId) + .firstElement() + .map(maxJoinIndex -> database.runInTransaction(() -> + upsertStreams(playlistId, streams, maxJoinIndex + 1)) + ).subscribeOn(Schedulers.io()); + } + + private List upsertStreams(final long playlistId, + final List streams, + final int indexOffset) { + + List joinEntities = new ArrayList<>(streams.size()); + final List streamIds = streamTable.upsertAll(streams); + for (int index = 0; index < streamIds.size(); index++) { + joinEntities.add(new PlaylistStreamEntity(playlistId, streamIds.get(index), + index + indexOffset)); + } + return playlistStreamTable.insertAll(joinEntities); + } + + public Completable updateJoin(final long playlistId, final List streamIds) { + List joinEntities = new ArrayList<>(streamIds.size()); + for (int i = 0; i < streamIds.size(); i++) { + joinEntities.add(new PlaylistStreamEntity(playlistId, streamIds.get(i), i)); + } + + return Completable.fromRunnable(() -> database.runInTransaction(() -> { + playlistStreamTable.deleteBatch(playlistId); + playlistStreamTable.insertAll(joinEntities); + })).subscribeOn(Schedulers.io()); + } + + public Flowable> getPlaylists() { + return playlistStreamTable.getPlaylistMetadata().subscribeOn(Schedulers.io()); + } + + public Flowable> getPlaylistStreams(final long playlistId) { + return playlistStreamTable.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io()); + } + + public Single deletePlaylist(final long playlistId) { + return Single.fromCallable(() -> playlistTable.deletePlaylist(playlistId)) + .subscribeOn(Schedulers.io()); + } + + public Maybe renamePlaylist(final long playlistId, final String name) { + return modifyPlaylist(playlistId, name, null); + } + + public Maybe changePlaylistThumbnail(final long playlistId, + final String thumbnailUrl) { + return modifyPlaylist(playlistId, null, thumbnailUrl); + } + + private Maybe modifyPlaylist(final long playlistId, + @Nullable final String name, + @Nullable final String thumbnailUrl) { + return playlistTable.getPlaylist(playlistId) + .firstElement() + .filter(playlistEntities -> !playlistEntities.isEmpty()) + .map(playlistEntities -> { + PlaylistEntity playlist = playlistEntities.get(0); + if (name != null) playlist.setName(name); + if (thumbnailUrl != null) playlist.setThumbnailUrl(thumbnailUrl); + return playlistTable.update(playlist); + }).subscribeOn(Schedulers.io()); + } + +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/RemotePlaylistManager.java b/app/src/main/java/org/schabi/newpipe/fragments/local/RemotePlaylistManager.java new file mode 100644 index 000000000..1e9be5638 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/RemotePlaylistManager.java @@ -0,0 +1,49 @@ +package org.schabi.newpipe.fragments.local; + +import org.schabi.newpipe.database.AppDatabase; +import org.schabi.newpipe.database.playlist.dao.PlaylistRemoteDAO; +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; +import org.schabi.newpipe.extractor.playlist.PlaylistInfo; + +import java.util.List; + +import io.reactivex.Flowable; +import io.reactivex.Single; +import io.reactivex.schedulers.Schedulers; + +public class RemotePlaylistManager { + + private final AppDatabase database; + private final PlaylistRemoteDAO playlistRemoteTable; + + public RemotePlaylistManager(final AppDatabase db) { + database = db; + playlistRemoteTable = db.playlistRemoteDAO(); + } + + public Flowable> getPlaylists() { + return playlistRemoteTable.getAll().subscribeOn(Schedulers.io()); + } + + public Flowable> getPlaylist(final PlaylistInfo info) { + return playlistRemoteTable.getPlaylist(info.getServiceId(), info.getUrl()) + .subscribeOn(Schedulers.io()); + } + + public Single deletePlaylist(final long playlistId) { + return Single.fromCallable(() -> playlistRemoteTable.deletePlaylist(playlistId)) + .subscribeOn(Schedulers.io()); + } + + public Single onBookmark(final PlaylistInfo playlistInfo) { + return Single.fromCallable(() -> { + final PlaylistRemoteEntity playlist = new PlaylistRemoteEntity(playlistInfo); + return playlistRemoteTable.upsert(playlist); + }).subscribeOn(Schedulers.io()); + } + + public Single onUpdate(final PlaylistInfo playlistInfo) { + return Single.fromCallable(() -> playlistRemoteTable.update(new PlaylistRemoteEntity(playlistInfo))) + .subscribeOn(Schedulers.io()); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/BaseLocalListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/BaseLocalListFragment.java new file mode 100644 index 000000000..eb366d97f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/BaseLocalListFragment.java @@ -0,0 +1,178 @@ +package org.schabi.newpipe.fragments.local.bookmark; + +import android.os.Bundle; +import android.support.v4.app.Fragment; +import android.support.v7.app.ActionBar; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.View; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.fragments.BaseStateFragment; +import org.schabi.newpipe.fragments.list.ListViewContract; +import org.schabi.newpipe.fragments.local.LocalItemListAdapter; + +import static org.schabi.newpipe.util.AnimationUtils.animateView; + +/** + * This fragment is design to be used with persistent data such as + * {@link org.schabi.newpipe.database.LocalItem}, and does not cache the data contained + * in the list adapter to avoid extra writes when the it exits or re-enters its lifecycle. + * + * This fragment destroys its adapter and views when {@link Fragment#onDestroyView()} is + * called and is memory efficient when in backstack. + * */ +public abstract class BaseLocalListFragment extends BaseStateFragment + implements ListViewContract { + + /*////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////*/ + + protected View headerRootView; + protected View footerRootView; + + protected LocalItemListAdapter itemListAdapter; + protected RecyclerView itemsList; + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle - Creation + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle - View + //////////////////////////////////////////////////////////////////////////*/ + + protected View getListHeader() { + return null; + } + + protected View getListFooter() { + return activity.getLayoutInflater().inflate(R.layout.pignate_footer, itemsList, false); + } + + protected RecyclerView.LayoutManager getListLayoutManager() { + return new LinearLayoutManager(activity); + } + + @Override + protected void initViews(View rootView, Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + + itemsList = rootView.findViewById(R.id.items_list); + itemsList.setLayoutManager(getListLayoutManager()); + + itemListAdapter = new LocalItemListAdapter(activity); + itemListAdapter.setHeader(headerRootView = getListHeader()); + itemListAdapter.setFooter(footerRootView = getListFooter()); + + itemsList.setAdapter(itemListAdapter); + } + + @Override + protected void initListeners() { + super.initListeners(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle - Menu + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + if (DEBUG) Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + + "], inflater = [" + inflater + "]"); + + final ActionBar supportActionBar = activity.getSupportActionBar(); + if (supportActionBar == null) return; + + supportActionBar.setDisplayShowTitleEnabled(true); + } + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle - Destruction + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onDestroyView() { + super.onDestroyView(); + itemsList = null; + itemListAdapter = null; + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void startLoading(boolean forceLoad) { + super.startLoading(forceLoad); + resetFragment(); + } + + @Override + public void showLoading() { + super.showLoading(); + if (itemsList != null) animateView(itemsList, false, 200); + if (headerRootView != null) animateView(headerRootView, false, 200); + } + + @Override + public void hideLoading() { + super.hideLoading(); + if (itemsList != null) animateView(itemsList, true, 200); + if (headerRootView != null) animateView(headerRootView, true, 200); + } + + @Override + public void showError(String message, boolean showRetryButton) { + super.showError(message, showRetryButton); + showListFooter(false); + + if (itemsList != null) animateView(itemsList, false, 200); + if (headerRootView != null) animateView(headerRootView, false, 200); + } + + @Override + public void showEmptyState() { + super.showEmptyState(); + showListFooter(false); + } + + @Override + public void showListFooter(final boolean show) { + if (itemsList == null) return; + itemsList.post(() -> { + if (itemListAdapter != null) itemListAdapter.showFooter(show); + }); + } + + @Override + public void handleNextItems(N result) { + isLoading.set(false); + } + + /*////////////////////////////////////////////////////////////////////////// + // Error handling + //////////////////////////////////////////////////////////////////////////*/ + + protected void resetFragment() { + if (itemListAdapter != null) itemListAdapter.clearStreamItemList(); + } + + @Override + protected boolean onError(Throwable exception) { + resetFragment(); + return super.onError(exception); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/BookmarkFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/BookmarkFragment.java new file mode 100644 index 000000000..21aceade8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/BookmarkFragment.java @@ -0,0 +1,309 @@ +package org.schabi.newpipe.fragments.local.bookmark; + +import android.app.AlertDialog; +import android.os.Bundle; +import android.os.Parcelable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.FragmentManager; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.schabi.newpipe.NewPipeDatabase; +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.AppDatabase; +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.playlist.PlaylistLocalItem; +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; +import org.schabi.newpipe.fragments.local.LocalPlaylistManager; +import org.schabi.newpipe.fragments.local.RemotePlaylistManager; +import org.schabi.newpipe.report.UserAction; +import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.OnClickGesture; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import icepick.State; +import io.reactivex.Flowable; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; + +public final class BookmarkFragment + extends BaseLocalListFragment, Void> { + + private View lastPlayedButton; + private View mostPlayedButton; + + @State + protected Parcelable itemsListState; + + private Subscription databaseSubscription; + private CompositeDisposable disposables = new CompositeDisposable(); + private LocalPlaylistManager localPlaylistManager; + private RemotePlaylistManager remotePlaylistManager; + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Creation + /////////////////////////////////////////////////////////////////////////// + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final AppDatabase database = NewPipeDatabase.getInstance(getContext()); + localPlaylistManager = new LocalPlaylistManager(database); + remotePlaylistManager = new RemotePlaylistManager(database); + disposables = new CompositeDisposable(); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + Bundle savedInstanceState) { + if (activity != null && activity.getSupportActionBar() != null) { + activity.getSupportActionBar().setDisplayShowTitleEnabled(true); + activity.setTitle(R.string.tab_subscriptions); + } + + return inflater.inflate(R.layout.fragment_bookmarks, container, false); + } + + + @Override + public void setUserVisibleHint(boolean isVisibleToUser) { + super.setUserVisibleHint(isVisibleToUser); + if (isVisibleToUser) setTitle(getString(R.string.tab_bookmarks)); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Views + /////////////////////////////////////////////////////////////////////////// + + @Override + protected void initViews(View rootView, Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + } + + @Override + protected View getListHeader() { + final View headerRootLayout = activity.getLayoutInflater() + .inflate(R.layout.bookmark_header, itemsList, false); + lastPlayedButton = headerRootLayout.findViewById(R.id.lastPlayed); + mostPlayedButton = headerRootLayout.findViewById(R.id.mostPlayed); + return headerRootLayout; + } + + @Override + protected void initListeners() { + super.initListeners(); + + itemListAdapter.setSelectedListener(new OnClickGesture() { + @Override + public void selected(LocalItem selectedItem) { + // Requires the parent fragment to find holder for fragment replacement + if (getParentFragment() == null) return; + final FragmentManager fragmentManager = getParentFragment().getFragmentManager(); + + if (selectedItem instanceof PlaylistMetadataEntry) { + final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem); + NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.uid, + entry.name); + + } else if (selectedItem instanceof PlaylistRemoteEntity) { + final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); + NavigationHelper.openPlaylistFragment(fragmentManager, entry.getServiceId(), + entry.getUrl(), entry.getName()); + } + } + + @Override + public void held(LocalItem selectedItem) { + if (selectedItem instanceof PlaylistMetadataEntry) { + showLocalDeleteDialog((PlaylistMetadataEntry) selectedItem); + + } else if (selectedItem instanceof PlaylistRemoteEntity) { + showRemoteDeleteDialog((PlaylistRemoteEntity) selectedItem); + } + } + }); + + lastPlayedButton.setOnClickListener(view -> { + if (getParentFragment() != null) { + NavigationHelper.openLastPlayedFragment(getParentFragment().getFragmentManager()); + } + }); + + mostPlayedButton.setOnClickListener(view -> { + if (getParentFragment() != null) { + NavigationHelper.openMostPlayedFragment(getParentFragment().getFragmentManager()); + } + }); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Loading + /////////////////////////////////////////////////////////////////////////// + + @Override + public void startLoading(boolean forceLoad) { + super.startLoading(forceLoad); + + Flowable.combineLatest( + localPlaylistManager.getPlaylists(), + remotePlaylistManager.getPlaylists(), + BookmarkFragment::merge + ).onBackpressureLatest() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getPlaylistsSubscriber()); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Destruction + /////////////////////////////////////////////////////////////////////////// + + @Override + public void onPause() { + super.onPause(); + itemsListState = itemsList.getLayoutManager().onSaveInstanceState(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + if (mostPlayedButton != null) mostPlayedButton.setOnClickListener(null); + if (lastPlayedButton != null) lastPlayedButton.setOnClickListener(null); + + if (disposables != null) disposables.clear(); + if (databaseSubscription != null) databaseSubscription.cancel(); + + databaseSubscription = null; + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (disposables != null) disposables.dispose(); + + disposables = null; + localPlaylistManager = null; + remotePlaylistManager = null; + itemsListState = null; + } + + /////////////////////////////////////////////////////////////////////////// + // Subscriptions Loader + /////////////////////////////////////////////////////////////////////////// + + private Subscriber> getPlaylistsSubscriber() { + return new Subscriber>() { + @Override + public void onSubscribe(Subscription s) { + showLoading(); + if (databaseSubscription != null) databaseSubscription.cancel(); + databaseSubscription = s; + databaseSubscription.request(1); + } + + @Override + public void onNext(List subscriptions) { + handleResult(subscriptions); + if (databaseSubscription != null) databaseSubscription.request(1); + } + + @Override + public void onError(Throwable exception) { + BookmarkFragment.this.onError(exception); + } + + @Override + public void onComplete() { + } + }; + } + + @Override + public void handleResult(@NonNull List result) { + super.handleResult(result); + + itemListAdapter.clearStreamItemList(); + + if (result.isEmpty()) { + showEmptyState(); + return; + } + + itemListAdapter.addItems(result); + if (itemsListState != null) { + itemsList.getLayoutManager().onRestoreInstanceState(itemsListState); + itemsListState = null; + } + hideLoading(); + } + /////////////////////////////////////////////////////////////////////////// + // Fragment Error Handling + /////////////////////////////////////////////////////////////////////////// + + @Override + protected boolean onError(Throwable exception) { + if (super.onError(exception)) return true; + + onUnrecoverableError(exception, UserAction.SOMETHING_ELSE, + "none", "Bookmark", R.string.general_error); + return true; + } + + @Override + protected void resetFragment() { + super.resetFragment(); + if (disposables != null) disposables.clear(); + } + + /////////////////////////////////////////////////////////////////////////// + // Utils + /////////////////////////////////////////////////////////////////////////// + + private void showLocalDeleteDialog(final PlaylistMetadataEntry item) { + showDeleteDialog(item.name, localPlaylistManager.deletePlaylist(item.uid)); + } + + private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) { + showDeleteDialog(item.getName(), remotePlaylistManager.deletePlaylist(item.getUid())); + } + + private void showDeleteDialog(final String name, final Single deleteReactor) { + if (activity == null || disposables == null) return; + + new AlertDialog.Builder(activity) + .setTitle(name) + .setMessage(R.string.delete_playlist_prompt) + .setCancelable(true) + .setPositiveButton(R.string.delete, (dialog, i) -> + disposables.add(deleteReactor + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignored -> {/*Do nothing on success*/}, this::onError)) + ) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + private static List merge(final List localPlaylists, + final List remotePlaylists) { + List items = new ArrayList<>( + localPlaylists.size() + remotePlaylists.size()); + items.addAll(localPlaylists); + items.addAll(remotePlaylists); + + Collections.sort(items, (left, right) -> + left.getOrderingName().compareToIgnoreCase(right.getOrderingName())); + + return items; + } +} + diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/LastPlayedFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/LastPlayedFragment.java new file mode 100644 index 000000000..a5b62c63e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/LastPlayedFragment.java @@ -0,0 +1,21 @@ +package org.schabi.newpipe.fragments.local.bookmark; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.stream.StreamStatisticsEntry; + +import java.util.Collections; +import java.util.List; + +public final class LastPlayedFragment extends StatisticsPlaylistFragment { + @Override + protected String getName() { + return getString(R.string.title_last_played); + } + + @Override + protected List processResult(List results) { + Collections.sort(results, (left, right) -> + right.latestAccessDate.compareTo(left.latestAccessDate)); + return results; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/LocalPlaylistFragment.java new file mode 100644 index 000000000..20eee38fc --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/LocalPlaylistFragment.java @@ -0,0 +1,590 @@ +package org.schabi.newpipe.fragments.local.bookmark; + +import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.os.Parcelable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.helper.ItemTouchHelper; +import android.text.TextUtils; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.schabi.newpipe.NewPipeDatabase; +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.fragments.local.LocalPlaylistManager; +import org.schabi.newpipe.info_list.InfoItemDialog; +import org.schabi.newpipe.playlist.PlayQueue; +import org.schabi.newpipe.playlist.SinglePlayQueue; +import org.schabi.newpipe.report.UserAction; +import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.OnClickGesture; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import icepick.State; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; +import io.reactivex.disposables.Disposables; +import io.reactivex.subjects.PublishSubject; + +import static org.schabi.newpipe.util.AnimationUtils.animateView; + +public class LocalPlaylistFragment extends BaseLocalListFragment, Void> { + + // Save the list 10 seconds after the last change occurred + private static final long SAVE_DEBOUNCE_MILLIS = 10000; + private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12; + + private View headerRootLayout; + private TextView headerTitleView; + private TextView headerStreamCount; + + private View playlistControl; + private View headerPlayAllButton; + private View headerPopupButton; + private View headerBackgroundButton; + + @State + protected Long playlistId; + @State + protected String name; + @State + protected Parcelable itemsListState; + + private ItemTouchHelper itemTouchHelper; + + private LocalPlaylistManager playlistManager; + private Subscription databaseSubscription; + + private PublishSubject debouncedSaveSignal; + private CompositeDisposable disposables; + + /* Has the playlist been fully loaded from db */ + private AtomicBoolean isLoadingComplete; + /* Has the playlist been modified (e.g. items reordered or deleted) */ + private AtomicBoolean isModified; + + public static LocalPlaylistFragment getInstance(long playlistId, String name) { + LocalPlaylistFragment instance = new LocalPlaylistFragment(); + instance.setInitialData(playlistId, name); + return instance; + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Creation + /////////////////////////////////////////////////////////////////////////// + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + playlistManager = new LocalPlaylistManager(NewPipeDatabase.getInstance(getContext())); + debouncedSaveSignal = PublishSubject.create(); + + disposables = new CompositeDisposable(); + + isLoadingComplete = new AtomicBoolean(); + isModified = new AtomicBoolean(); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_playlist, container, false); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Lifecycle - Views + /////////////////////////////////////////////////////////////////////////// + + @Override + public void setTitle(final String title) { + super.setTitle(title); + + if (headerTitleView != null) { + headerTitleView.setText(title); + } + } + + @Override + protected void initViews(View rootView, Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + setTitle(name); + } + + @Override + protected View getListHeader() { + headerRootLayout = activity.getLayoutInflater().inflate(R.layout.local_playlist_header, + itemsList, false); + + headerTitleView = headerRootLayout.findViewById(R.id.playlist_title_view); + headerTitleView.setSelected(true); + + headerStreamCount = headerRootLayout.findViewById(R.id.playlist_stream_count); + + playlistControl = headerRootLayout.findViewById(R.id.playlist_control); + headerPlayAllButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_all_button); + headerPopupButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_popup_button); + headerBackgroundButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_bg_button); + + return headerRootLayout; + } + + @Override + protected void initListeners() { + super.initListeners(); + + headerTitleView.setOnClickListener(view -> createRenameDialog()); + + itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); + itemTouchHelper.attachToRecyclerView(itemsList); + + itemListAdapter.setSelectedListener(new OnClickGesture() { + @Override + public void selected(LocalItem selectedItem) { + if (selectedItem instanceof PlaylistStreamEntry) { + final PlaylistStreamEntry item = (PlaylistStreamEntry) selectedItem; + NavigationHelper.openVideoDetailFragment(getFragmentManager(), + item.serviceId, item.url, item.title); + } + } + + @Override + public void held(LocalItem selectedItem) { + if (selectedItem instanceof PlaylistStreamEntry) { + showStreamDialog((PlaylistStreamEntry) selectedItem); + } + } + + @Override + public void drag(LocalItem selectedItem, RecyclerView.ViewHolder viewHolder) { + if (itemTouchHelper != null) itemTouchHelper.startDrag(viewHolder); + } + }); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Lifecycle - Loading + /////////////////////////////////////////////////////////////////////////// + + @Override + public void showLoading() { + super.showLoading(); + if (headerRootLayout != null) animateView(headerRootLayout, false, 200); + if (playlistControl != null) animateView(playlistControl, false, 200); + } + + @Override + public void hideLoading() { + super.hideLoading(); + if (headerRootLayout != null) animateView(headerRootLayout, true, 200); + if (playlistControl != null) animateView(playlistControl, true, 200); + } + + @Override + public void startLoading(boolean forceLoad) { + super.startLoading(forceLoad); + + if (disposables != null) disposables.clear(); + disposables.add(getDebouncedSaver()); + + isLoadingComplete.set(false); + isModified.set(false); + + playlistManager.getPlaylistStreams(playlistId) + .onBackpressureLatest() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getPlaylistObserver()); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Lifecycle - Destruction + /////////////////////////////////////////////////////////////////////////// + + @Override + public void onPause() { + super.onPause(); + itemsListState = itemsList.getLayoutManager().onSaveInstanceState(); + + // Save on exit + saveImmediate(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + if (itemListAdapter != null) itemListAdapter.unsetSelectedListener(); + if (headerBackgroundButton != null) headerBackgroundButton.setOnClickListener(null); + if (headerPlayAllButton != null) headerPlayAllButton.setOnClickListener(null); + if (headerPopupButton != null) headerPopupButton.setOnClickListener(null); + + if (databaseSubscription != null) databaseSubscription.cancel(); + if (disposables != null) disposables.clear(); + + databaseSubscription = null; + itemTouchHelper = null; + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (debouncedSaveSignal != null) debouncedSaveSignal.onComplete(); + if (disposables != null) disposables.dispose(); + + debouncedSaveSignal = null; + playlistManager = null; + disposables = null; + + isLoadingComplete = null; + isModified = null; + } + + /////////////////////////////////////////////////////////////////////////// + // Playlist Stream Loader + /////////////////////////////////////////////////////////////////////////// + + private Subscriber> getPlaylistObserver() { + return new Subscriber>() { + @Override + public void onSubscribe(Subscription s) { + showLoading(); + isLoadingComplete.set(false); + + if (databaseSubscription != null) databaseSubscription.cancel(); + databaseSubscription = s; + databaseSubscription.request(1); + } + + @Override + public void onNext(List streams) { + // Skip handling the result after it has been modified + if (isModified == null || !isModified.get()) { + handleResult(streams); + isLoadingComplete.set(true); + } + + if (databaseSubscription != null) databaseSubscription.request(1); + } + + @Override + public void onError(Throwable exception) { + LocalPlaylistFragment.this.onError(exception); + } + + @Override + public void onComplete() {} + }; + } + + @Override + public void handleResult(@NonNull List result) { + super.handleResult(result); + if (itemListAdapter == null) return; + + itemListAdapter.clearStreamItemList(); + + if (result.isEmpty()) { + showEmptyState(); + return; + } + + itemListAdapter.addItems(result); + if (itemsListState != null) { + itemsList.getLayoutManager().onRestoreInstanceState(itemsListState); + itemsListState = null; + } + setVideoCount(itemListAdapter.getItemsList().size()); + + headerPlayAllButton.setOnClickListener(view -> + NavigationHelper.playOnMainPlayer(activity, getPlayQueue())); + headerPopupButton.setOnClickListener(view -> + NavigationHelper.playOnPopupPlayer(activity, getPlayQueue())); + headerBackgroundButton.setOnClickListener(view -> + NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue())); + + hideLoading(); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Error Handling + /////////////////////////////////////////////////////////////////////////// + + @Override + protected void resetFragment() { + super.resetFragment(); + if (databaseSubscription != null) databaseSubscription.cancel(); + } + + @Override + protected boolean onError(Throwable exception) { + if (super.onError(exception)) return true; + + onUnrecoverableError(exception, UserAction.SOMETHING_ELSE, + "none", "Local Playlist", R.string.general_error); + return true; + } + + /*////////////////////////////////////////////////////////////////////////// + // Playlist Metadata/Streams Manipulation + //////////////////////////////////////////////////////////////////////////*/ + + private void createRenameDialog() { + if (playlistId == null || name == null || getContext() == null) return; + + final View dialogView = View.inflate(getContext(), R.layout.dialog_playlist_name, null); + EditText nameEdit = dialogView.findViewById(R.id.playlist_name); + nameEdit.setText(name); + nameEdit.setSelection(nameEdit.getText().length()); + + final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(getContext()) + .setTitle(R.string.rename_playlist) + .setView(dialogView) + .setCancelable(true) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.rename, (dialogInterface, i) -> { + changePlaylistName(nameEdit.getText().toString()); + }); + + dialogBuilder.show(); + } + + private void changePlaylistName(final String name) { + if (playlistManager == null) return; + + this.name = name; + setTitle(name); + + Log.d(TAG, "Updating playlist id=[" + playlistId + + "] with new name=[" + name + "] items"); + + final Disposable disposable = playlistManager.renamePlaylist(playlistId, name) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(longs -> {/*Do nothing on success*/}, this::onError); + disposables.add(disposable); + } + + private void changeThumbnailUrl(final String thumbnailUrl) { + if (playlistManager == null) return; + + final Toast successToast = Toast.makeText(getActivity(), + R.string.playlist_thumbnail_change_success, + Toast.LENGTH_SHORT); + + Log.d(TAG, "Updating playlist id=[" + playlistId + + "] with new thumbnail url=[" + thumbnailUrl + "]"); + + final Disposable disposable = playlistManager + .changePlaylistThumbnail(playlistId, thumbnailUrl) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignore -> successToast.show(), this::onError); + disposables.add(disposable); + } + + private void deleteItem(final PlaylistStreamEntry item) { + if (itemListAdapter == null) return; + + itemListAdapter.removeItem(item); + setVideoCount(itemListAdapter.getItemsList().size()); + saveChanges(); + } + + private void saveChanges() { + if (isModified == null || debouncedSaveSignal == null) return; + + isModified.set(true); + debouncedSaveSignal.onNext(System.currentTimeMillis()); + } + + private Disposable getDebouncedSaver() { + if (debouncedSaveSignal == null) return Disposables.empty(); + + return debouncedSaveSignal + .debounce(SAVE_DEBOUNCE_MILLIS, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignored -> saveImmediate(), this::onError); + } + + private void saveImmediate() { + if (playlistManager == null || itemListAdapter == null) return; + + // List must be loaded and modified in order to save + if (isLoadingComplete == null || isModified == null || + !isLoadingComplete.get() || !isModified.get()) { + Log.w(TAG, "Attempting to save playlist when local playlist " + + "is not loaded or not modified: playlist id=[" + playlistId + "]"); + return; + } + + final List items = itemListAdapter.getItemsList(); + List streamIds = new ArrayList<>(items.size()); + for (final LocalItem item : items) { + if (item instanceof PlaylistStreamEntry) { + streamIds.add(((PlaylistStreamEntry) item).streamId); + } + } + + Log.d(TAG, "Updating playlist id=[" + playlistId + + "] with [" + streamIds.size() + "] items"); + + final Disposable disposable = playlistManager.updateJoin(playlistId, streamIds) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + () -> { if (isModified != null) isModified.set(false); }, + this::onError + ); + disposables.add(disposable); + } + + + private ItemTouchHelper.SimpleCallback getItemTouchCallback() { + return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, + ItemTouchHelper.ACTION_STATE_IDLE) { + @Override + public int interpolateOutOfBoundsScroll(RecyclerView recyclerView, int viewSize, + int viewSizeOutOfBounds, int totalSize, + long msSinceStartScroll) { + final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, + viewSizeOutOfBounds, totalSize, msSinceStartScroll); + final int minimumAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY, + Math.abs(standardSpeed)); + return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); + } + + @Override + public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder source, + RecyclerView.ViewHolder target) { + if (source.getItemViewType() != target.getItemViewType() || + itemListAdapter == null) { + return false; + } + + final int sourceIndex = source.getAdapterPosition(); + final int targetIndex = target.getAdapterPosition(); + final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex); + if (isSwapped) saveChanges(); + return isSwapped; + } + + @Override + public boolean isLongPressDragEnabled() { + return false; + } + + @Override + public boolean isItemViewSwipeEnabled() { + return false; + } + + @Override + public void onSwiped(RecyclerView.ViewHolder viewHolder, int swipeDir) {} + }; + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + protected void showStreamDialog(final PlaylistStreamEntry item) { + final Context context = getContext(); + final Activity activity = getActivity(); + if (context == null || context.getResources() == null || getActivity() == null) return; + + final StreamInfoItem infoItem = item.toStreamInfoItem(); + + final String[] commands = new String[]{ + context.getResources().getString(R.string.enqueue_on_background), + context.getResources().getString(R.string.enqueue_on_popup), + context.getResources().getString(R.string.start_here_on_main), + context.getResources().getString(R.string.start_here_on_background), + context.getResources().getString(R.string.start_here_on_popup), + context.getResources().getString(R.string.set_as_playlist_thumbnail), + context.getResources().getString(R.string.delete) + }; + + final DialogInterface.OnClickListener actions = (dialogInterface, i) -> { + final int index = Math.max(itemListAdapter.getItemsList().indexOf(item), 0); + switch (i) { + case 0: + NavigationHelper.enqueueOnBackgroundPlayer(context, + new SinglePlayQueue(infoItem)); + break; + case 1: + NavigationHelper.enqueueOnPopupPlayer(activity, new + SinglePlayQueue(infoItem)); + break; + case 2: + NavigationHelper.playOnMainPlayer(context, getPlayQueue(index)); + break; + case 3: + NavigationHelper.playOnBackgroundPlayer(context, getPlayQueue(index)); + break; + case 4: + NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(index)); + break; + case 5: + changeThumbnailUrl(item.thumbnailUrl); + break; + case 6: + deleteItem(item); + break; + default: + break; + } + }; + + new InfoItemDialog(getActivity(), infoItem, commands, actions).show(); + } + + private void setInitialData(long playlistId, String name) { + this.playlistId = playlistId; + this.name = !TextUtils.isEmpty(name) ? name : ""; + } + + private void setVideoCount(final long count) { + if (activity != null && headerStreamCount != null) { + headerStreamCount.setText(Localization.localizeStreamCount(activity, count)); + } + } + + private PlayQueue getPlayQueue() { + return getPlayQueue(0); + } + + private PlayQueue getPlayQueue(final int index) { + if (itemListAdapter == null) { + return new SinglePlayQueue(Collections.emptyList(), 0); + } + + final List infoItems = itemListAdapter.getItemsList(); + List streamInfoItems = new ArrayList<>(infoItems.size()); + for (final LocalItem item : infoItems) { + if (item instanceof PlaylistStreamEntry) { + streamInfoItems.add(((PlaylistStreamEntry) item).toStreamInfoItem()); + } + } + return new SinglePlayQueue(streamInfoItems, index); + } +} + diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/MostPlayedFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/MostPlayedFragment.java new file mode 100644 index 000000000..cba9e9c64 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/MostPlayedFragment.java @@ -0,0 +1,22 @@ +package org.schabi.newpipe.fragments.local.bookmark; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.stream.StreamStatisticsEntry; + +import java.util.Collections; +import java.util.List; + +public final class MostPlayedFragment extends StatisticsPlaylistFragment { + @Override + protected String getName() { + return getString(R.string.title_most_played); + } + + @Override + protected List processResult(List results) { + Collections.sort(results, (left, right) -> + ((Long) right.watchCount).compareTo(left.watchCount)); + return results; + } + +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/StatisticsPlaylistFragment.java new file mode 100644 index 000000000..d9bbc68c8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/StatisticsPlaylistFragment.java @@ -0,0 +1,300 @@ +package org.schabi.newpipe.fragments.local.bookmark; + +import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.os.Parcelable; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.stream.StreamStatisticsEntry; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.history.HistoryRecordManager; +import org.schabi.newpipe.info_list.InfoItemDialog; +import org.schabi.newpipe.playlist.PlayQueue; +import org.schabi.newpipe.playlist.SinglePlayQueue; +import org.schabi.newpipe.report.UserAction; +import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.OnClickGesture; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import icepick.State; +import io.reactivex.android.schedulers.AndroidSchedulers; + +public abstract class StatisticsPlaylistFragment + extends BaseLocalListFragment, Void> { + + private View headerPlayAllButton; + private View headerPopupButton; + private View headerBackgroundButton; + + @State + protected Parcelable itemsListState; + + /* Used for independent events */ + private Subscription databaseSubscription; + private HistoryRecordManager recordManager; + + /////////////////////////////////////////////////////////////////////////// + // Abstracts + /////////////////////////////////////////////////////////////////////////// + + protected abstract String getName(); + + protected abstract List processResult(final List results); + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Creation + /////////////////////////////////////////////////////////////////////////// + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + recordManager = new HistoryRecordManager(getContext()); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_playlist, container, false); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Views + /////////////////////////////////////////////////////////////////////////// + + @Override + protected void initViews(View rootView, Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + setTitle(getName()); + } + + @Override + protected View getListHeader() { + final View headerRootLayout = activity.getLayoutInflater().inflate(R.layout.playlist_control, + itemsList, false); + headerPlayAllButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_all_button); + headerPopupButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_popup_button); + headerBackgroundButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_bg_button); + return headerRootLayout; + } + + @Override + protected void initListeners() { + super.initListeners(); + + itemListAdapter.setSelectedListener(new OnClickGesture() { + @Override + public void selected(LocalItem selectedItem) { + if (selectedItem instanceof StreamStatisticsEntry) { + final StreamStatisticsEntry item = (StreamStatisticsEntry) selectedItem; + NavigationHelper.openVideoDetailFragment(getFragmentManager(), + item.serviceId, item.url, item.title); + } + } + + @Override + public void held(LocalItem selectedItem) { + if (selectedItem instanceof StreamStatisticsEntry) { + showStreamDialog((StreamStatisticsEntry) selectedItem); + } + } + }); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Loading + /////////////////////////////////////////////////////////////////////////// + + @Override + public void startLoading(boolean forceLoad) { + super.startLoading(forceLoad); + recordManager.getStreamStatistics() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getHistoryObserver()); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Destruction + /////////////////////////////////////////////////////////////////////////// + + @Override + public void onPause() { + super.onPause(); + itemsListState = itemsList.getLayoutManager().onSaveInstanceState(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + if (itemListAdapter != null) itemListAdapter.unsetSelectedListener(); + if (headerBackgroundButton != null) headerBackgroundButton.setOnClickListener(null); + if (headerPlayAllButton != null) headerPlayAllButton.setOnClickListener(null); + if (headerPopupButton != null) headerPopupButton.setOnClickListener(null); + + if (databaseSubscription != null) databaseSubscription.cancel(); + databaseSubscription = null; + } + + @Override + public void onDestroy() { + super.onDestroy(); + recordManager = null; + itemsListState = null; + } + + /////////////////////////////////////////////////////////////////////////// + // Statistics Loader + /////////////////////////////////////////////////////////////////////////// + + private Subscriber> getHistoryObserver() { + return new Subscriber>() { + @Override + public void onSubscribe(Subscription s) { + showLoading(); + + if (databaseSubscription != null) databaseSubscription.cancel(); + databaseSubscription = s; + databaseSubscription.request(1); + } + + @Override + public void onNext(List streams) { + handleResult(streams); + if (databaseSubscription != null) databaseSubscription.request(1); + } + + @Override + public void onError(Throwable exception) { + StatisticsPlaylistFragment.this.onError(exception); + } + + @Override + public void onComplete() { + } + }; + } + + @Override + public void handleResult(@NonNull List result) { + super.handleResult(result); + if (itemListAdapter == null) return; + + itemListAdapter.clearStreamItemList(); + + if (result.isEmpty()) { + showEmptyState(); + return; + } + + itemListAdapter.addItems(processResult(result)); + if (itemsListState != null) { + itemsList.getLayoutManager().onRestoreInstanceState(itemsListState); + itemsListState = null; + } + + headerPlayAllButton.setOnClickListener(view -> + NavigationHelper.playOnMainPlayer(activity, getPlayQueue())); + headerPopupButton.setOnClickListener(view -> + NavigationHelper.playOnPopupPlayer(activity, getPlayQueue())); + headerBackgroundButton.setOnClickListener(view -> + NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue())); + + hideLoading(); + } + /////////////////////////////////////////////////////////////////////////// + // Fragment Error Handling + /////////////////////////////////////////////////////////////////////////// + + @Override + protected void resetFragment() { + super.resetFragment(); + if (databaseSubscription != null) databaseSubscription.cancel(); + } + + @Override + protected boolean onError(Throwable exception) { + if (super.onError(exception)) return true; + + onUnrecoverableError(exception, UserAction.SOMETHING_ELSE, + "none", "History Statistics", R.string.general_error); + return true; + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + private void showStreamDialog(final StreamStatisticsEntry item) { + final Context context = getContext(); + final Activity activity = getActivity(); + if (context == null || context.getResources() == null || getActivity() == null) return; + final StreamInfoItem infoItem = item.toStreamInfoItem(); + + final String[] commands = new String[]{ + context.getResources().getString(R.string.enqueue_on_background), + context.getResources().getString(R.string.enqueue_on_popup), + context.getResources().getString(R.string.start_here_on_main), + context.getResources().getString(R.string.start_here_on_background), + context.getResources().getString(R.string.start_here_on_popup), + }; + + final DialogInterface.OnClickListener actions = (dialogInterface, i) -> { + final int index = Math.max(itemListAdapter.getItemsList().indexOf(item), 0); + switch (i) { + case 0: + NavigationHelper.enqueueOnBackgroundPlayer(context, new SinglePlayQueue(infoItem)); + break; + case 1: + NavigationHelper.enqueueOnPopupPlayer(activity, new SinglePlayQueue(infoItem)); + break; + case 2: + NavigationHelper.playOnMainPlayer(context, getPlayQueue(index)); + break; + case 3: + NavigationHelper.playOnBackgroundPlayer(context, getPlayQueue(index)); + break; + case 4: + NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(index)); + break; + default: + break; + } + }; + + new InfoItemDialog(getActivity(), infoItem, commands, actions).show(); + } + + private PlayQueue getPlayQueue() { + return getPlayQueue(0); + } + + private PlayQueue getPlayQueue(final int index) { + if (itemListAdapter == null) { + return new SinglePlayQueue(Collections.emptyList(), 0); + } + + final List infoItems = itemListAdapter.getItemsList(); + List streamInfoItems = new ArrayList<>(infoItems.size()); + for (final LocalItem item : infoItems) { + if (item instanceof StreamStatisticsEntry) { + streamInfoItems.add(((StreamStatisticsEntry) item).toStreamInfoItem()); + } + } + return new SinglePlayQueue(streamInfoItems, index); + } +} + diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/dialog/PlaylistAppendDialog.java b/app/src/main/java/org/schabi/newpipe/fragments/local/dialog/PlaylistAppendDialog.java new file mode 100644 index 000000000..da31ca3f8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/dialog/PlaylistAppendDialog.java @@ -0,0 +1,161 @@ +package org.schabi.newpipe.fragments.local.dialog; + +import android.annotation.SuppressLint; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import org.schabi.newpipe.NewPipeDatabase; +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; +import org.schabi.newpipe.database.stream.model.StreamEntity; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.fragments.local.LocalItemListAdapter; +import org.schabi.newpipe.fragments.local.LocalPlaylistManager; +import org.schabi.newpipe.playlist.PlayQueueItem; +import org.schabi.newpipe.util.OnClickGesture; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; + +public final class PlaylistAppendDialog extends PlaylistDialog { + private static final String TAG = PlaylistAppendDialog.class.getCanonicalName(); + + private RecyclerView playlistRecyclerView; + private LocalItemListAdapter playlistAdapter; + + private Disposable playlistReactor; + + public static PlaylistAppendDialog fromStreamInfo(final StreamInfo info) { + PlaylistAppendDialog dialog = new PlaylistAppendDialog(); + dialog.setInfo(Collections.singletonList(new StreamEntity(info))); + return dialog; + } + + public static PlaylistAppendDialog fromStreamInfoItems(final List items) { + PlaylistAppendDialog dialog = new PlaylistAppendDialog(); + List entities = new ArrayList<>(items.size()); + for (final StreamInfoItem item : items) { + entities.add(new StreamEntity(item)); + } + dialog.setInfo(entities); + return dialog; + } + + public static PlaylistAppendDialog fromPlayQueueItems(final List items) { + PlaylistAppendDialog dialog = new PlaylistAppendDialog(); + List entities = new ArrayList<>(items.size()); + for (final PlayQueueItem item : items) { + entities.add(new StreamEntity(item)); + } + dialog.setInfo(entities); + return dialog; + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle - Creation + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + return inflater.inflate(R.layout.dialog_playlists, container); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + final LocalPlaylistManager playlistManager = + new LocalPlaylistManager(NewPipeDatabase.getInstance(getContext())); + + playlistAdapter = new LocalItemListAdapter(getActivity()); + playlistAdapter.setSelectedListener(new OnClickGesture() { + @Override + public void selected(LocalItem selectedItem) { + if (!(selectedItem instanceof PlaylistMetadataEntry) || getStreams() == null) + return; + onPlaylistSelected(playlistManager, (PlaylistMetadataEntry) selectedItem, + getStreams()); + } + }); + + playlistRecyclerView = view.findViewById(R.id.playlist_list); + playlistRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + playlistRecyclerView.setAdapter(playlistAdapter); + + final View newPlaylistButton = view.findViewById(R.id.newPlaylist); + newPlaylistButton.setOnClickListener(ignored -> openCreatePlaylistDialog()); + + playlistReactor = playlistManager.getPlaylists() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::onPlaylistsReceived); + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle - Destruction + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onDestroyView() { + super.onDestroyView(); + if (playlistReactor != null) playlistReactor.dispose(); + if (playlistAdapter != null) playlistAdapter.unsetSelectedListener(); + + playlistReactor = null; + playlistRecyclerView = null; + playlistAdapter = null; + } + + /*////////////////////////////////////////////////////////////////////////// + // Helper + //////////////////////////////////////////////////////////////////////////*/ + + public void openCreatePlaylistDialog() { + if (getStreams() == null || getFragmentManager() == null) return; + + PlaylistCreationDialog.newInstance(getStreams()).show(getFragmentManager(), TAG); + getDialog().dismiss(); + } + + private void onPlaylistsReceived(@NonNull final List playlists) { + if (playlists.isEmpty()) { + openCreatePlaylistDialog(); + return; + } + + if (playlistAdapter != null && playlistRecyclerView != null) { + playlistAdapter.clearStreamItemList(); + playlistAdapter.addItems(playlists); + playlistRecyclerView.setVisibility(View.VISIBLE); + } + } + + private void onPlaylistSelected(@NonNull LocalPlaylistManager manager, + @NonNull PlaylistMetadataEntry playlist, + @NonNull List streams) { + if (getStreams() == null) return; + + @SuppressLint("ShowToast") + final Toast successToast = Toast.makeText(getContext(), + R.string.playlist_add_stream_success, Toast.LENGTH_SHORT); + + manager.appendToPlaylist(playlist.uid, streams) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignored -> successToast.show()); + + getDialog().dismiss(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/dialog/PlaylistCreationDialog.java b/app/src/main/java/org/schabi/newpipe/fragments/local/dialog/PlaylistCreationDialog.java new file mode 100644 index 000000000..f721e7701 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/dialog/PlaylistCreationDialog.java @@ -0,0 +1,62 @@ +package org.schabi.newpipe.fragments.local.dialog; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.view.View; +import android.widget.EditText; +import android.widget.Toast; + +import org.schabi.newpipe.NewPipeDatabase; +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.stream.model.StreamEntity; +import org.schabi.newpipe.fragments.local.LocalPlaylistManager; + +import java.util.List; + +import io.reactivex.android.schedulers.AndroidSchedulers; + +public final class PlaylistCreationDialog extends PlaylistDialog { + private static final String TAG = PlaylistCreationDialog.class.getCanonicalName(); + + public static PlaylistCreationDialog newInstance(final List streams) { + PlaylistCreationDialog dialog = new PlaylistCreationDialog(); + dialog.setInfo(streams); + return dialog; + } + + /*////////////////////////////////////////////////////////////////////////// + // Dialog + //////////////////////////////////////////////////////////////////////////*/ + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + if (getStreams() == null) return super.onCreateDialog(savedInstanceState); + + View dialogView = View.inflate(getContext(), R.layout.dialog_playlist_name, null); + EditText nameInput = dialogView.findViewById(R.id.playlist_name); + + final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(getContext()) + .setTitle(R.string.create_playlist) + .setView(dialogView) + .setCancelable(true) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.create, (dialogInterface, i) -> { + final String name = nameInput.getText().toString(); + final LocalPlaylistManager playlistManager = + new LocalPlaylistManager(NewPipeDatabase.getInstance(getContext())); + final Toast successToast = Toast.makeText(getActivity(), + R.string.playlist_creation_success, + Toast.LENGTH_SHORT); + + playlistManager.createPlaylist(name, getStreams()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(longs -> successToast.show()); + }); + + return dialogBuilder.create(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/dialog/PlaylistDialog.java b/app/src/main/java/org/schabi/newpipe/fragments/local/dialog/PlaylistDialog.java new file mode 100644 index 000000000..a632988c4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/dialog/PlaylistDialog.java @@ -0,0 +1,73 @@ +package org.schabi.newpipe.fragments.local.dialog; + +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.DialogFragment; + +import org.schabi.newpipe.database.stream.model.StreamEntity; +import org.schabi.newpipe.util.StateSaver; + +import java.util.List; +import java.util.Queue; + +public abstract class PlaylistDialog extends DialogFragment implements StateSaver.WriteRead { + + private List streamEntities; + + private StateSaver.SavedState savedState; + + protected void setInfo(final List entities) { + this.streamEntities = entities; + } + + protected List getStreams() { + return streamEntities; + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + savedState = StateSaver.tryToRestore(savedInstanceState, this); + } + + @Override + public void onDestroy() { + super.onDestroy(); + StateSaver.onDestroy(savedState); + } + + /*////////////////////////////////////////////////////////////////////////// + // State Saving + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public String generateSuffix() { + final int size = streamEntities == null ? 0 : streamEntities.size(); + return "." + size + ".list"; + } + + @Override + public void writeTo(Queue objectsToSave) { + objectsToSave.add(streamEntities); + } + + @Override + @SuppressWarnings("unchecked") + public void readFrom(@NonNull Queue savedObjects) throws Exception { + streamEntities = (List) savedObjects.poll(); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if (getActivity() != null) { + savedState = StateSaver.tryToSave(getActivity().isChangingConfigurations(), + savedState, outState, this); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalItemHolder.java b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalItemHolder.java new file mode 100644 index 000000000..2dffdbfdb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalItemHolder.java @@ -0,0 +1,42 @@ +package org.schabi.newpipe.fragments.local.holder; + +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.fragments.local.LocalItemBuilder; + +import java.text.DateFormat; + +/* + * Created by Christian Schabesberger on 12.02.17. + * + * Copyright (C) Christian Schabesberger 2016 + * InfoItemHolder.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ + +public abstract class LocalItemHolder extends RecyclerView.ViewHolder { + protected final LocalItemBuilder itemBuilder; + + public LocalItemHolder(LocalItemBuilder itemBuilder, int layoutId, ViewGroup parent) { + super(LayoutInflater.from(itemBuilder.getContext()) + .inflate(layoutId, parent, false)); + this.itemBuilder = itemBuilder; + } + + public abstract void updateFromItem(final LocalItem item, final DateFormat dateFormat); +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalPlaylistItemHolder.java new file mode 100644 index 000000000..d9eb7caa5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalPlaylistItemHolder.java @@ -0,0 +1,33 @@ +package org.schabi.newpipe.fragments.local.holder; + +import android.view.View; +import android.view.ViewGroup; + +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; +import org.schabi.newpipe.fragments.local.LocalItemBuilder; +import org.schabi.newpipe.util.ImageDisplayConstants; + +import java.text.DateFormat; + +public class LocalPlaylistItemHolder extends PlaylistItemHolder { + + public LocalPlaylistItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) { + super(infoItemBuilder, parent); + } + + @Override + public void updateFromItem(final LocalItem localItem, final DateFormat dateFormat) { + if (!(localItem instanceof PlaylistMetadataEntry)) return; + final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem; + + itemTitleView.setText(item.name); + itemStreamCountView.setText(String.valueOf(item.streamCount)); + itemUploaderView.setVisibility(View.INVISIBLE); + + itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView, + ImageDisplayConstants.DISPLAY_PLAYLIST_OPTIONS); + + super.updateFromItem(localItem, dateFormat); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalPlaylistStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalPlaylistStreamItemHolder.java new file mode 100644 index 000000000..5f9555d9f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalPlaylistStreamItemHolder.java @@ -0,0 +1,93 @@ +package org.schabi.newpipe.fragments.local.holder; + +import android.support.v4.content.ContextCompat; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.fragments.local.LocalItemBuilder; +import org.schabi.newpipe.util.ImageDisplayConstants; +import org.schabi.newpipe.util.Localization; + +import java.text.DateFormat; + +public class LocalPlaylistStreamItemHolder extends LocalItemHolder { + + public final ImageView itemThumbnailView; + public final TextView itemVideoTitleView; + public final TextView itemAdditionalDetailsView; + public final TextView itemDurationView; + public final View itemHandleView; + + LocalPlaylistStreamItemHolder(LocalItemBuilder infoItemBuilder, int layoutId, ViewGroup parent) { + super(infoItemBuilder, layoutId, parent); + + itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); + itemVideoTitleView = itemView.findViewById(R.id.itemVideoTitleView); + itemAdditionalDetailsView = itemView.findViewById(R.id.itemAdditionalDetails); + itemDurationView = itemView.findViewById(R.id.itemDurationView); + itemHandleView = itemView.findViewById(R.id.itemHandle); + } + + public LocalPlaylistStreamItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) { + this(infoItemBuilder, R.layout.list_stream_playlist_item, parent); + } + + @Override + public void updateFromItem(final LocalItem localItem, final DateFormat dateFormat) { + if (!(localItem instanceof PlaylistStreamEntry)) return; + final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem; + + itemVideoTitleView.setText(item.title); + itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.uploader, + NewPipe.getNameOfService(item.serviceId))); + + if (item.duration > 0) { + itemDurationView.setText(Localization.getDurationString(item.duration)); + itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), + R.color.duration_background_color)); + itemDurationView.setVisibility(View.VISIBLE); + } else { + itemDurationView.setVisibility(View.GONE); + } + + // Default thumbnail is shown on error, while loading and if the url is empty + itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS); + + itemView.setOnClickListener(view -> { + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().selected(item); + } + }); + + itemView.setLongClickable(true); + itemView.setOnLongClickListener(view -> { + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().held(item); + } + return true; + }); + + itemThumbnailView.setOnTouchListener(getOnTouchListener(item)); + itemHandleView.setOnTouchListener(getOnTouchListener(item)); + } + + private View.OnTouchListener getOnTouchListener(final PlaylistStreamEntry item) { + return (view, motionEvent) -> { + view.performClick(); + if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null && + motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { + itemBuilder.getOnItemSelectedListener().drag(item, + LocalPlaylistStreamItemHolder.this); + } + return false; + }; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalStatisticStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalStatisticStreamItemHolder.java new file mode 100644 index 000000000..199158672 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalStatisticStreamItemHolder.java @@ -0,0 +1,103 @@ +package org.schabi.newpipe.fragments.local.holder; + +import android.support.v4.content.ContextCompat; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.stream.StreamStatisticsEntry; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.fragments.local.LocalItemBuilder; +import org.schabi.newpipe.util.ImageDisplayConstants; +import org.schabi.newpipe.util.Localization; + +import java.text.DateFormat; + +/* + * Created by Christian Schabesberger on 01.08.16. + *

+ * Copyright (C) Christian Schabesberger 2016 + * StreamInfoItemHolder.java is part of NewPipe. + *

+ * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *

+ * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ + +public class LocalStatisticStreamItemHolder extends LocalItemHolder { + + public final ImageView itemThumbnailView; + public final TextView itemVideoTitleView; + public final TextView itemUploaderView; + public final TextView itemDurationView; + public final TextView itemAdditionalDetails; + + public LocalStatisticStreamItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) { + super(infoItemBuilder, R.layout.list_stream_item, parent); + + itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); + itemVideoTitleView = itemView.findViewById(R.id.itemVideoTitleView); + itemUploaderView = itemView.findViewById(R.id.itemUploaderView); + itemDurationView = itemView.findViewById(R.id.itemDurationView); + itemAdditionalDetails = itemView.findViewById(R.id.itemAdditionalDetails); + } + + private String getStreamInfoDetailLine(final StreamStatisticsEntry entry, + final DateFormat dateFormat) { + final String watchCount = Localization.shortViewCount(itemBuilder.getContext(), + entry.watchCount); + final String uploadDate = dateFormat.format(entry.latestAccessDate); + final String serviceName = NewPipe.getNameOfService(entry.serviceId); + return Localization.concatenateStrings(watchCount, uploadDate, serviceName); + } + + @Override + public void updateFromItem(final LocalItem localItem, final DateFormat dateFormat) { + if (!(localItem instanceof StreamStatisticsEntry)) return; + final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem; + + itemVideoTitleView.setText(item.title); + itemUploaderView.setText(item.uploader); + + if (item.duration > 0) { + itemDurationView.setText(Localization.getDurationString(item.duration)); + itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), + R.color.duration_background_color)); + itemDurationView.setVisibility(View.VISIBLE); + } else { + itemDurationView.setVisibility(View.GONE); + } + + itemAdditionalDetails.setText(getStreamInfoDetailLine(item, dateFormat)); + + // Default thumbnail is shown on error, while loading and if the url is empty + itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS); + + itemView.setOnClickListener(view -> { + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().selected(item); + } + }); + + itemView.setLongClickable(true); + itemView.setOnLongClickListener(view -> { + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().held(item); + } + return true; + }); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/holder/PlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/PlaylistItemHolder.java new file mode 100644 index 000000000..57bc2a3cb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/PlaylistItemHolder.java @@ -0,0 +1,49 @@ +package org.schabi.newpipe.fragments.local.holder; + +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.fragments.local.LocalItemBuilder; + +import java.text.DateFormat; + +public abstract class PlaylistItemHolder extends LocalItemHolder { + public final ImageView itemThumbnailView; + public final TextView itemStreamCountView; + public final TextView itemTitleView; + public final TextView itemUploaderView; + + public PlaylistItemHolder(LocalItemBuilder infoItemBuilder, + int layoutId, ViewGroup parent) { + super(infoItemBuilder, layoutId, parent); + + itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); + itemTitleView = itemView.findViewById(R.id.itemTitleView); + itemStreamCountView = itemView.findViewById(R.id.itemStreamCountView); + itemUploaderView = itemView.findViewById(R.id.itemUploaderView); + } + + public PlaylistItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) { + this(infoItemBuilder, R.layout.list_playlist_mini_item, parent); + } + + @Override + public void updateFromItem(final LocalItem localItem, final DateFormat dateFormat) { + itemView.setOnClickListener(view -> { + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().selected(localItem); + } + }); + + itemView.setLongClickable(true); + itemView.setOnLongClickListener(view -> { + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().held(localItem); + } + return true; + }); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/holder/RemotePlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/RemotePlaylistItemHolder.java new file mode 100644 index 000000000..871138464 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/RemotePlaylistItemHolder.java @@ -0,0 +1,34 @@ +package org.schabi.newpipe.fragments.local.holder; + +import android.view.ViewGroup; + +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.fragments.local.LocalItemBuilder; +import org.schabi.newpipe.util.ImageDisplayConstants; +import org.schabi.newpipe.util.Localization; + +import java.text.DateFormat; + +public class RemotePlaylistItemHolder extends PlaylistItemHolder { + public RemotePlaylistItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) { + super(infoItemBuilder, parent); + } + + @Override + public void updateFromItem(final LocalItem localItem, final DateFormat dateFormat) { + if (!(localItem instanceof PlaylistRemoteEntity)) return; + final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem; + + itemTitleView.setText(item.getName()); + itemStreamCountView.setText(String.valueOf(item.getStreamCount())); + itemUploaderView.setText(Localization.concatenateStrings(item.getUploader(), + NewPipe.getNameOfService(item.getServiceId()))); + + itemBuilder.displayImage(item.getThumbnailUrl(), itemThumbnailView, + ImageDisplayConstants.DISPLAY_PLAYLIST_OPTIONS); + + super.updateFromItem(localItem, dateFormat); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/subscription/ImportConfirmationDialog.java b/app/src/main/java/org/schabi/newpipe/fragments/subscription/ImportConfirmationDialog.java new file mode 100644 index 000000000..3a64c22c7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/subscription/ImportConfirmationDialog.java @@ -0,0 +1,64 @@ +package org.schabi.newpipe.fragments.subscription; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.DialogFragment; +import android.support.v4.app.Fragment; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.util.ThemeHelper; + +import icepick.Icepick; +import icepick.State; + +public class ImportConfirmationDialog extends DialogFragment { + @State + protected Intent resultServiceIntent; + + public void setResultServiceIntent(Intent resultServiceIntent) { + this.resultServiceIntent = resultServiceIntent; + } + + public static void show(@NonNull Fragment fragment, @NonNull Intent resultServiceIntent) { + if (fragment.getFragmentManager() == null) return; + + final ImportConfirmationDialog confirmationDialog = new ImportConfirmationDialog(); + confirmationDialog.setResultServiceIntent(resultServiceIntent); + confirmationDialog.show(fragment.getFragmentManager(), null); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + return new AlertDialog.Builder(getContext(), ThemeHelper.getDialogTheme(getContext())) + .setMessage(R.string.import_network_expensive_warning) + .setCancelable(true) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { + if (resultServiceIntent != null && getContext() != null) { + getContext().startService(resultServiceIntent); + } + dismiss(); + }) + .create(); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (resultServiceIntent == null) throw new IllegalStateException("Result intent is null"); + + Icepick.restoreInstanceState(this, savedInstanceState); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + Icepick.saveInstanceState(this, outState); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionFragment.java index 662f617bb..a0124f570 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionFragment.java @@ -1,30 +1,62 @@ package org.schabi.newpipe.fragments.subscription; +import android.app.Activity; +import android.content.BroadcastReceiver; import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.graphics.Color; +import android.graphics.PorterDuff; import android.os.Bundle; +import android.os.Environment; import android.os.Parcelable; +import android.support.annotation.DrawableRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.v4.content.LocalBroadcastManager; +import android.support.v7.app.ActionBar; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import com.nononsenseapps.filepicker.Utils; import org.schabi.newpipe.R; import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.channel.ChannelInfoItem; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; import org.schabi.newpipe.fragments.BaseStateFragment; -import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.info_list.InfoListAdapter; import org.schabi.newpipe.report.UserAction; +import org.schabi.newpipe.subscription.SubscriptionService; +import org.schabi.newpipe.subscription.services.SubscriptionsExportService; +import org.schabi.newpipe.subscription.services.SubscriptionsImportService; +import org.schabi.newpipe.util.FilePickerActivityHelper; import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.OnClickGesture; +import org.schabi.newpipe.util.ServiceHelper; +import org.schabi.newpipe.util.ThemeHelper; +import org.schabi.newpipe.views.CollapsibleView; +import java.io.File; +import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.Date; import java.util.List; +import java.util.Locale; import icepick.State; import io.reactivex.Observer; @@ -33,18 +65,29 @@ import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; +import static org.schabi.newpipe.subscription.services.SubscriptionsImportService.KEY_MODE; +import static org.schabi.newpipe.subscription.services.SubscriptionsImportService.KEY_VALUE; +import static org.schabi.newpipe.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE; +import static org.schabi.newpipe.util.AnimationUtils.animateRotation; import static org.schabi.newpipe.util.AnimationUtils.animateView; public class SubscriptionFragment extends BaseStateFragment> { - private View headerRootLayout; + private static final int REQUEST_EXPORT_CODE = 666; + private static final int REQUEST_IMPORT_CODE = 667; - private InfoListAdapter infoListAdapter; private RecyclerView itemsList; - @State protected Parcelable itemsListState; + private InfoListAdapter infoListAdapter; + + private View headerRootLayout; + private View whatsNewItemListHeader; + private View importExportListHeader; + + @State + protected Parcelable importExportOptionsState; + private CollapsibleView importExportOptions; - /* Used for independent events */ private CompositeDisposable disposables = new CompositeDisposable(); private SubscriptionService subscriptionService; @@ -52,39 +95,48 @@ public class SubscriptionFragment extends BaseStateFragment onImportPreviousSelected()); + + final int iconColor = ThemeHelper.isLightThemeSelected(getContext()) ? Color.BLACK : Color.WHITE; + final String[] services = getResources().getStringArray(R.array.service_list); + for (String serviceName : services) { + try { + final StreamingService service = NewPipe.getService(serviceName); + + final SubscriptionExtractor subscriptionExtractor = service.getSubscriptionExtractor(); + if (subscriptionExtractor == null) continue; + + final List supportedSources = subscriptionExtractor.getSupportedSources(); + if (supportedSources.isEmpty()) continue; + + final View itemView = addItemView(serviceName, ServiceHelper.getIcon(service.getServiceId()), listHolder); + final ImageView iconView = itemView.findViewById(android.R.id.icon1); + iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN); + + itemView.setOnClickListener(selectedItem -> onImportFromServiceSelected(service.getServiceId())); + } catch (ExtractionException e) { + throw new RuntimeException("Services array contains an entry that it's not a valid service name (" + serviceName + ")", e); + } + } + } + + private void setupExportToItems(final ViewGroup listHolder) { + final View previousBackupItem = addItemView(getString(R.string.file), ThemeHelper.resolveResourceIdFromAttr(getContext(), R.attr.ic_save), listHolder); + previousBackupItem.setOnClickListener(item -> onExportSelected()); + } + + private void onImportFromServiceSelected(int serviceId) { + if (getParentFragment() == null) return; + NavigationHelper.openSubscriptionsImportFragment(getParentFragment().getFragmentManager(), serviceId); + } + + private void onImportPreviousSelected() { + startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE); + } + + private void onExportSelected() { + final String date = new SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(new Date()); + final String exportName = "newpipe_subscriptions_" + date + ".json"; + final File exportFile = new File(Environment.getExternalStorageDirectory(), exportName); + + startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.getAbsolutePath()), REQUEST_EXPORT_CODE); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (data != null && data.getData() != null && resultCode == Activity.RESULT_OK) { + if (requestCode == REQUEST_EXPORT_CODE) { + final File exportFile = Utils.getFileForUri(data.getData()); + if (!exportFile.getParentFile().canWrite() || !exportFile.getParentFile().canRead()) { + Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show(); + } else { + activity.startService(new Intent(activity, SubscriptionsExportService.class) + .putExtra(SubscriptionsExportService.KEY_FILE_PATH, exportFile.getAbsolutePath())); + } + } else if (requestCode == REQUEST_IMPORT_CODE) { + final String path = Utils.getFileForUri(data.getData()).getAbsolutePath(); + ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class) + .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE) + .putExtra(KEY_VALUE, path)); + } + } + } + /*///////////////////////////////////////////////////////////////////////// // Fragment Views - /////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////*/ @Override protected void initViews(View rootView, Bundle savedInstanceState) { @@ -116,33 +290,46 @@ public class SubscriptionFragment extends BaseStateFragment animateRotation(iconView, 250, newState == CollapsibleView.COLLAPSED ? 0 : 180); } @Override protected void initListeners() { super.initListeners(); - infoListAdapter.setOnChannelSelectedListener(new InfoItemBuilder.OnInfoItemSelectedListener() { + infoListAdapter.setOnChannelSelectedListener(new OnClickGesture() { @Override public void selected(ChannelInfoItem selectedItem) { // Requires the parent fragment to find holder for fragment replacement - NavigationHelper.openChannelFragment(getParentFragment().getFragmentManager(), selectedItem.getServiceId(), selectedItem.url, selectedItem.getName()); - - } - - @Override - public void held(ChannelInfoItem selectedItem) {} - }); - - headerRootLayout.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - NavigationHelper.openWhatsNewFragment(getParentFragment().getFragmentManager()); + NavigationHelper.openChannelFragment(getParentFragment().getFragmentManager(), + selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName()); } }); + + //noinspection ConstantConditions + whatsNewItemListHeader.setOnClickListener(v -> + NavigationHelper.openWhatsNewFragment(getParentFragment().getFragmentManager())); + importExportListHeader.setOnClickListener(v -> importExportOptions.switchState()); } private void resetFragment() { @@ -196,6 +383,7 @@ public class SubscriptionFragment extends BaseStateFragment items = new ArrayList<>(); for (final SubscriptionEntity subscription : subscriptions) items.add(subscription.toChannelInfoItem()); - Collections.sort(items, new Comparator() { - @Override - public int compare(InfoItem o1, InfoItem o2) { - return o1.name.compareToIgnoreCase(o2.name); - } - }); + Collections.sort(items, + (InfoItem o1, InfoItem o2) -> o1.getName().compareToIgnoreCase(o2.getName())); return items; } @@ -238,12 +422,6 @@ public class SubscriptionFragment extends BaseStateFragment supportedSources; + private String relatedUrl; + @StringRes + private int instructionsString; + + public static SubscriptionsImportFragment getInstance(int serviceId) { + SubscriptionsImportFragment instance = new SubscriptionsImportFragment(); + instance.setInitialData(serviceId); + return instance; + } + + public void setInitialData(int serviceId) { + this.currentServiceId = serviceId; + } + + /*////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////*/ + + private TextView infoTextView; + + private EditText inputText; + private Button inputButton; + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle + /////////////////////////////////////////////////////////////////////////// + + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setupServiceVariables(); + if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) { + ErrorActivity.reportError(activity, Collections.emptyList(), null, null, ErrorActivity.ErrorInfo.make(UserAction.SOMETHING_ELSE, + NewPipe.getNameOfService(currentServiceId), "Service don't support importing", R.string.general_error)); + activity.finish(); + } + } + + @Override + public void setUserVisibleHint(boolean isVisibleToUser) { + super.setUserVisibleHint(isVisibleToUser); + if (isVisibleToUser) { + setTitle(getString(R.string.import_title)); + } + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_import, container, false); + } + + /*///////////////////////////////////////////////////////////////////////// + // Fragment Views + /////////////////////////////////////////////////////////////////////////*/ + + @Override + protected void initViews(View rootView, Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + + inputButton = rootView.findViewById(R.id.input_button); + inputText = rootView.findViewById(R.id.input_text); + + infoTextView = rootView.findViewById(R.id.info_text_view); + + // TODO: Support services that can import from more than one source (show the option to the user) + if (supportedSources.contains(CHANNEL_URL)) { + inputButton.setText(R.string.import_title); + inputText.setVisibility(View.VISIBLE); + inputText.setHint(ServiceHelper.getImportInstructionsHint(currentServiceId)); + } else { + inputButton.setText(R.string.import_file_title); + } + + if (instructionsString != 0) { + if (TextUtils.isEmpty(relatedUrl)) { + setInfoText(getString(instructionsString)); + } else { + setInfoText(getString(instructionsString, relatedUrl)); + } + } else { + setInfoText(""); + } + + ActionBar supportActionBar = activity.getSupportActionBar(); + if (supportActionBar != null) { + supportActionBar.setDisplayShowTitleEnabled(true); + setTitle(getString(R.string.import_title)); + } + } + + @Override + protected void initListeners() { + super.initListeners(); + inputButton.setOnClickListener(v -> onImportClicked()); + } + + private void onImportClicked() { + if (inputText.getVisibility() == View.VISIBLE) { + final String value = inputText.getText().toString(); + if (!value.isEmpty()) onImportUrl(value); + } else { + onImportFile(); + } + } + + public void onImportUrl(String value) { + ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class) + .putExtra(KEY_MODE, CHANNEL_URL_MODE) + .putExtra(KEY_VALUE, value) + .putExtra(Constants.KEY_SERVICE_ID, currentServiceId)); + } + + public void onImportFile() { + startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_FILE_CODE); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (data == null) return; + + if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_IMPORT_FILE_CODE && data.getData() != null) { + final String path = Utils.getFileForUri(data.getData()).getAbsolutePath(); + ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class) + .putExtra(KEY_MODE, INPUT_STREAM_MODE) + .putExtra(KEY_VALUE, path) + .putExtra(Constants.KEY_SERVICE_ID, currentServiceId)); + } + } + + /////////////////////////////////////////////////////////////////////////// + // Subscriptions + /////////////////////////////////////////////////////////////////////////// + + private void setupServiceVariables() { + if (currentServiceId != Constants.NO_SERVICE_ID) { + try { + final SubscriptionExtractor extractor = NewPipe.getService(currentServiceId).getSubscriptionExtractor(); + supportedSources = extractor.getSupportedSources(); + relatedUrl = extractor.getRelatedUrl(); + instructionsString = ServiceHelper.getImportInstructions(currentServiceId); + return; + } catch (ExtractionException ignored) { + } + } + + supportedSources = Collections.emptyList(); + relatedUrl = null; + instructionsString = 0; + } + + private void setInfoText(String infoString) { + infoTextView.setText(infoString); + LinkifyCompat.addLinks(infoTextView, Linkify.WEB_URLS); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/history/HistoryActivity.java b/app/src/main/java/org/schabi/newpipe/history/HistoryActivity.java index 8d8e4ef16..267d07065 100644 --- a/app/src/main/java/org/schabi/newpipe/history/HistoryActivity.java +++ b/app/src/main/java/org/schabi/newpipe/history/HistoryActivity.java @@ -11,7 +11,6 @@ import android.support.v4.app.FragmentStatePagerAdapter; import android.support.v4.view.ViewPager; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.Toolbar; -import android.util.Log; import android.view.Menu; import android.view.MenuItem; @@ -22,7 +21,6 @@ import org.schabi.newpipe.settings.SettingsActivity; import org.schabi.newpipe.util.ThemeHelper; import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.functions.Consumer; public class HistoryActivity extends AppCompatActivity { @@ -50,8 +48,10 @@ public class HistoryActivity extends AppCompatActivity { Toolbar toolbar = findViewById(R.id.toolbar); setSupportActionBar(toolbar); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - getSupportActionBar().setTitle(R.string.title_activity_history); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setTitle(R.string.title_activity_history); + } // Create the adapter that will return a fragment for each of the three // primary sections of the activity. mSectionsPagerAdapter = new SectionsPagerAdapter(getSupportFragmentManager()); @@ -66,17 +66,11 @@ public class HistoryActivity extends AppCompatActivity { final FloatingActionButton fab = findViewById(R.id.fab); RxView.clicks(fab) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Consumer() { - @Override - public void accept(Object o) { - int currentItem = mViewPager.getCurrentItem(); - HistoryFragment fragment = (HistoryFragment) mSectionsPagerAdapter.instantiateItem(mViewPager, currentItem); - if(fragment != null) { - fragment.onHistoryCleared(); - } else { - Log.w(TAG, "Couldn't find current fragment"); - } - } + .subscribe(ignored -> { + int currentItem = mViewPager.getCurrentItem(); + HistoryFragment fragment = (HistoryFragment) mSectionsPagerAdapter + .instantiateItem(mViewPager, currentItem); + fragment.onHistoryCleared(); }); } @@ -119,7 +113,7 @@ public class HistoryActivity extends AppCompatActivity { fragment = SearchHistoryFragment.newInstance(); break; case 1: - fragment = WatchedHistoryFragment.newInstance(); + fragment = WatchHistoryFragment.newInstance(); break; default: throw new IllegalArgumentException("position: " + position); diff --git a/app/src/main/java/org/schabi/newpipe/history/HistoryEntryAdapter.java b/app/src/main/java/org/schabi/newpipe/history/HistoryEntryAdapter.java index d56469a7e..4170a1139 100644 --- a/app/src/main/java/org/schabi/newpipe/history/HistoryEntryAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/history/HistoryEntryAdapter.java @@ -1,12 +1,12 @@ package org.schabi.newpipe.history; import android.content.Context; +import android.content.res.Resources; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v7.widget.RecyclerView; -import android.view.View; -import org.schabi.newpipe.database.history.model.HistoryEntry; +import org.schabi.newpipe.util.Localization; import java.text.DateFormat; import java.util.ArrayList; @@ -19,19 +19,20 @@ import java.util.Date; * @param the type of the entries * @param the type of the view holder */ -public abstract class HistoryEntryAdapter extends RecyclerView.Adapter { +public abstract class HistoryEntryAdapter extends RecyclerView.Adapter { private final ArrayList mEntries; private final DateFormat mDateFormat; + private final Context mContext; private OnHistoryItemClickListener onHistoryItemClickListener = null; public HistoryEntryAdapter(Context context) { super(); + mContext = context; mEntries = new ArrayList<>(); - mDateFormat = android.text.format.DateFormat.getDateFormat(context.getApplicationContext()); - - setHasStableIds(true); + mDateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM, + Localization.getPreferredLocale(context)); } public void setEntries(@NonNull Collection historyEntries) { @@ -53,9 +54,8 @@ public abstract class HistoryEntryAdapter historyItemClickListener = onHistoryItemClickListener; - if(historyItemClickListener != null) { - historyItemClickListener.onHistoryItemClick(entry); - } + holder.itemView.setOnClickListener(v -> { + if(onHistoryItemClickListener != null) { + onHistoryItemClickListener.onHistoryItemClick(entry); } }); + + holder.itemView.setOnLongClickListener(view -> { + if (onHistoryItemClickListener != null) { + onHistoryItemClickListener.onHistoryItemLongClick(entry); + return true; + } + return false; + }); + onBindViewHolder(holder, entry, position); } @@ -94,13 +99,8 @@ public abstract class HistoryEntryAdapter { - void onHistoryItemClick(E historyItem); + public interface OnHistoryItemClickListener { + void onHistoryItemClick(E item); + void onHistoryItemLongClick(E item); } } diff --git a/app/src/main/java/org/schabi/newpipe/history/HistoryFragment.java b/app/src/main/java/org/schabi/newpipe/history/HistoryFragment.java index c64689775..14bd93c57 100644 --- a/app/src/main/java/org/schabi/newpipe/history/HistoryFragment.java +++ b/app/src/main/java/org/schabi/newpipe/history/HistoryFragment.java @@ -2,7 +2,6 @@ package org.schabi.newpipe.history; import android.content.SharedPreferences; -import android.graphics.Color; import android.os.Bundle; import android.os.Parcelable; import android.preference.PreferenceManager; @@ -12,34 +11,33 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.StringRes; import android.support.design.widget.Snackbar; +import android.support.v7.app.AlertDialog; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; -import android.support.v7.widget.helper.ItemTouchHelper; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; import org.schabi.newpipe.BaseFragment; import org.schabi.newpipe.R; -import org.schabi.newpipe.database.history.dao.HistoryDAO; -import org.schabi.newpipe.database.history.model.HistoryEntry; import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; import java.util.List; import icepick.State; -import io.reactivex.Observer; +import io.reactivex.Flowable; +import io.reactivex.Single; import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.Disposable; -import io.reactivex.functions.Consumer; -import io.reactivex.schedulers.Schedulers; -import io.reactivex.subjects.PublishSubject; import static org.schabi.newpipe.util.AnimationUtils.animateView; -public abstract class HistoryFragment extends BaseFragment +public abstract class HistoryFragment extends BaseFragment implements HistoryEntryAdapter.OnHistoryItemClickListener { private SharedPreferences mSharedPreferences; @@ -54,12 +52,11 @@ public abstract class HistoryFragment extends BaseFragme Parcelable mRecyclerViewState; private RecyclerView mRecyclerView; private HistoryEntryAdapter mHistoryAdapter; - private ItemTouchHelper.SimpleCallback mHistoryItemSwipeCallback; - // private int allowedSwipeToDeleteDirections = ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; - private HistoryDAO mHistoryDataSource; - private PublishSubject> mHistoryEntryDeleteSubject; - private PublishSubject> mHistoryEntryInsertSubject; + private Subscription historySubscription; + + protected HistoryRecordManager historyRecordManager; + protected CompositeDisposable disposables; @StringRes abstract int getEnabledConfigKey(); @@ -77,88 +74,47 @@ public abstract class HistoryFragment extends BaseFragme // Register history enabled listener mSharedPreferences.registerOnSharedPreferenceChangeListener(mHistoryIsEnabledChangeListener); - mHistoryDataSource = createHistoryDAO(); - - mHistoryEntryDeleteSubject = PublishSubject.create(); - mHistoryEntryDeleteSubject - .observeOn(Schedulers.io()) - .subscribe(new Consumer>() { - @Override - public void accept(Collection historyEntries) throws Exception { - mHistoryDataSource.delete(historyEntries); - } - }); - - mHistoryEntryInsertSubject = PublishSubject.create(); - mHistoryEntryInsertSubject - .observeOn(Schedulers.io()) - .subscribe(new Consumer>() { - @Override - public void accept(Collection historyEntries) throws Exception { - mHistoryDataSource.insertAll(historyEntries); - } - }); - - - } - - protected void historyItemSwipeCallback(int swipeDirection) { - mHistoryItemSwipeCallback = new ItemTouchHelper.SimpleCallback(0, swipeDirection) { - @Override - public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) { - return false; - } - - @Override - public void onSwiped(RecyclerView.ViewHolder viewHolder, int swipeDir) { - if (mHistoryAdapter != null) { - final E historyEntry = mHistoryAdapter.removeItemAt(viewHolder.getAdapterPosition()); - mHistoryEntryDeleteSubject.onNext(Collections.singletonList(historyEntry)); - - View view = getActivity().findViewById(R.id.main_content); - if (view == null) view = mRecyclerView.getRootView(); - - Snackbar.make(view, R.string.item_deleted, 5 * 1000) - .setActionTextColor(Color.WHITE) - .setAction(R.string.undo, new View.OnClickListener() { - @Override - public void onClick(View v) { - mHistoryEntryInsertSubject.onNext(Collections.singletonList(historyEntry)); - } - }).show(); - } - } - }; + historyRecordManager = new HistoryRecordManager(getContext()); + disposables = new CompositeDisposable(); } @NonNull protected abstract HistoryEntryAdapter createAdapter(); + protected abstract Single> insert(final Collection entries); + + protected abstract Single delete(final Collection entries); + + @NonNull + protected abstract Flowable> getAll(); + @Override public void onResume() { super.onResume(); - mHistoryDataSource.getAll() - .toObservable() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getHistoryListConsumer()); - boolean newEnabled = isHistoryEnabled(); + + getAll().observeOn(AndroidSchedulers.mainThread()).subscribe(getHistorySubscriber()); + + final boolean newEnabled = isHistoryEnabled(); if (newEnabled != mHistoryIsEnabled) { onHistoryIsEnabledChanged(newEnabled); } } @NonNull - private Observer> getHistoryListConsumer() { - return new Observer>() { + private Subscriber> getHistorySubscriber() { + return new Subscriber>() { @Override - public void onSubscribe(@NonNull Disposable d) { + public void onSubscribe(Subscription s) { + if (historySubscription != null) historySubscription.cancel(); + historySubscription = s; + historySubscription.request(1); } @Override - public void onNext(@NonNull List historyEntries) { - if (!historyEntries.isEmpty()) { - mHistoryAdapter.setEntries(historyEntries); + public void onNext(List entries) { + if (!entries.isEmpty()) { + mHistoryAdapter.setEntries(entries); animateView(mEmptyHistoryView, false, 200); if (mRecyclerViewState != null) { @@ -169,11 +125,13 @@ public abstract class HistoryFragment extends BaseFragme mHistoryAdapter.clear(); showEmptyHistory(); } + + if (historySubscription != null) historySubscription.request(1); } @Override - public void onError(@NonNull Throwable e) { - // TODO: error handling like in (see e.g. subscription fragment) + public void onError(Throwable t) { + } @Override @@ -192,30 +150,48 @@ public abstract class HistoryFragment extends BaseFragme */ @MainThread public void onHistoryCleared() { - final Parcelable stateBeforeClear = mRecyclerView.getLayoutManager().onSaveInstanceState(); - final Collection itemsToDelete = new ArrayList<>(mHistoryAdapter.getItems()); - mHistoryEntryDeleteSubject.onNext(itemsToDelete); + if (getContext() == null) return; + + new AlertDialog.Builder(getContext()) + .setTitle(R.string.delete_all) + .setMessage(R.string.delete_all_history_prompt) + .setCancelable(true) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.delete_all, (dialog, i) -> clearHistory()) + .show(); + } + + protected void makeSnackbar(@StringRes final int text) { + if (getActivity() == null) return; View view = getActivity().findViewById(R.id.main_content); if (view == null) view = mRecyclerView.getRootView(); + Snackbar.make(view, text, Snackbar.LENGTH_LONG).show(); + } - if (!itemsToDelete.isEmpty()) { - Snackbar.make(view, R.string.history_cleared, 5 * 1000) - .setActionTextColor(Color.WHITE) - .setAction(R.string.undo, new View.OnClickListener() { - @Override - public void onClick(View v) { - mRecyclerViewState = stateBeforeClear; - mHistoryEntryInsertSubject.onNext(itemsToDelete); - } - }).show(); - } else { - Snackbar.make(view, R.string.history_cleared, Snackbar.LENGTH_LONG).show(); - } + private void clearHistory() { + final Collection itemsToDelete = new ArrayList<>(mHistoryAdapter.getItems()); + final Disposable deletion = delete(itemsToDelete) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + ignored -> Log.d(TAG, "Clear history deleted [" + + itemsToDelete.size() + "] items."), + error -> Log.e(TAG, "Clear history delete step failed", error) + ); + + final Disposable cleanUp = historyRecordManager.removeOrphanedRecords() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + ignored -> Log.d(TAG, "Clear history deleted orphaned stream records"), + error -> Log.e(TAG, "Clear history remove orphaned records failed", error) + ); + + disposables.addAll(deletion, cleanUp); + + makeSnackbar(R.string.history_cleared); mHistoryAdapter.clear(); showEmptyHistory(); - } private void showEmptyHistory() { @@ -227,18 +203,18 @@ public abstract class HistoryFragment extends BaseFragme @Nullable @CallSuper @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + public View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { View rootView = inflater.inflate(R.layout.fragment_history, container, false); mRecyclerView = rootView.findViewById(R.id.history_view); - RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false); + RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(getContext(), + LinearLayoutManager.VERTICAL, false); mRecyclerView.setLayoutManager(layoutManager); mHistoryAdapter = createAdapter(); mHistoryAdapter.setOnHistoryItemClickListener(this); mRecyclerView.setAdapter(mHistoryAdapter); - ItemTouchHelper itemTouchHelper = new ItemTouchHelper(mHistoryItemSwipeCallback); - itemTouchHelper.attachToRecyclerView(mRecyclerView); mDisabledView = rootView.findViewById(R.id.history_disabled_view); mEmptyHistoryView = rootView.findViewById(R.id.history_empty); @@ -256,11 +232,16 @@ public abstract class HistoryFragment extends BaseFragme @Override public void onDestroy() { super.onDestroy(); + + if (disposables != null) disposables.dispose(); + if (historySubscription != null) historySubscription.cancel(); + mSharedPreferences.unregisterOnSharedPreferenceChangeListener(mHistoryIsEnabledChangeListener); mSharedPreferences = null; mHistoryIsEnabledChangeListener = null; mHistoryIsEnabledKey = null; - mHistoryDataSource = null; + historySubscription = null; + disposables = null; } @Override @@ -290,15 +271,8 @@ public abstract class HistoryFragment extends BaseFragme } } - /** - * Creates a new history DAO - * - * @return the history DAO - */ - @NonNull - protected abstract HistoryDAO createHistoryDAO(); - - private class HistoryIsEnabledChangeListener implements SharedPreferences.OnSharedPreferenceChangeListener { + private class HistoryIsEnabledChangeListener + implements SharedPreferences.OnSharedPreferenceChangeListener { @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { if (key.equals(mHistoryIsEnabledKey)) { diff --git a/app/src/main/java/org/schabi/newpipe/history/HistoryRecordManager.java b/app/src/main/java/org/schabi/newpipe/history/HistoryRecordManager.java new file mode 100644 index 000000000..9d3ffaffe --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/history/HistoryRecordManager.java @@ -0,0 +1,191 @@ +package org.schabi.newpipe.history; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.support.annotation.NonNull; + +import org.schabi.newpipe.NewPipeDatabase; +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.AppDatabase; +import org.schabi.newpipe.database.history.dao.SearchHistoryDAO; +import org.schabi.newpipe.database.history.dao.StreamHistoryDAO; +import org.schabi.newpipe.database.history.model.SearchHistoryEntry; +import org.schabi.newpipe.database.history.model.StreamHistoryEntity; +import org.schabi.newpipe.database.history.model.StreamHistoryEntry; +import org.schabi.newpipe.database.stream.StreamStatisticsEntry; +import org.schabi.newpipe.database.stream.dao.StreamDAO; +import org.schabi.newpipe.database.stream.dao.StreamStateDAO; +import org.schabi.newpipe.database.stream.model.StreamEntity; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; +import org.schabi.newpipe.extractor.stream.StreamInfo; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.List; + +import io.reactivex.Flowable; +import io.reactivex.Maybe; +import io.reactivex.Single; +import io.reactivex.schedulers.Schedulers; + +public class HistoryRecordManager { + + private final AppDatabase database; + private final StreamDAO streamTable; + private final StreamHistoryDAO streamHistoryTable; + private final SearchHistoryDAO searchHistoryTable; + private final StreamStateDAO streamStateTable; + private final SharedPreferences sharedPreferences; + private final String searchHistoryKey; + private final String streamHistoryKey; + + public HistoryRecordManager(final Context context) { + database = NewPipeDatabase.getInstance(context); + streamTable = database.streamDAO(); + streamHistoryTable = database.streamHistoryDAO(); + searchHistoryTable = database.searchHistoryDAO(); + streamStateTable = database.streamStateDAO(); + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + searchHistoryKey = context.getString(R.string.enable_search_history_key); + streamHistoryKey = context.getString(R.string.enable_watch_history_key); + } + + /////////////////////////////////////////////////////// + // Watch History + /////////////////////////////////////////////////////// + + public Maybe onViewed(final StreamInfo info) { + if (!isStreamHistoryEnabled()) return Maybe.empty(); + + final Date currentTime = new Date(); + return Maybe.fromCallable(() -> database.runInTransaction(() -> { + final long streamId = streamTable.upsert(new StreamEntity(info)); + StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(); + + if (latestEntry != null && latestEntry.getStreamUid() == streamId) { + streamHistoryTable.delete(latestEntry); + latestEntry.setAccessDate(currentTime); + latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1); + return streamHistoryTable.insert(latestEntry); + } else { + return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime)); + } + })).subscribeOn(Schedulers.io()); + } + + public Single deleteStreamHistory(final long streamId) { + return Single.fromCallable(() -> streamHistoryTable.deleteStreamHistory(streamId)) + .subscribeOn(Schedulers.io()); + } + + public Flowable> getStreamHistory() { + return streamHistoryTable.getHistory().subscribeOn(Schedulers.io()); + } + + public Flowable> getStreamStatistics() { + return streamHistoryTable.getStatistics().subscribeOn(Schedulers.io()); + } + + public Single> insertStreamHistory(final Collection entries) { + List entities = new ArrayList<>(entries.size()); + for (final StreamHistoryEntry entry : entries) { + entities.add(entry.toStreamHistoryEntity()); + } + return Single.fromCallable(() -> streamHistoryTable.insertAll(entities)) + .subscribeOn(Schedulers.io()); + } + + public Single deleteStreamHistory(final Collection entries) { + List entities = new ArrayList<>(entries.size()); + for (final StreamHistoryEntry entry : entries) { + entities.add(entry.toStreamHistoryEntity()); + } + return Single.fromCallable(() -> streamHistoryTable.delete(entities)) + .subscribeOn(Schedulers.io()); + } + + private boolean isStreamHistoryEnabled() { + return sharedPreferences.getBoolean(streamHistoryKey, false); + } + + /////////////////////////////////////////////////////// + // Search History + /////////////////////////////////////////////////////// + + public Single> insertSearches(final Collection entries) { + return Single.fromCallable(() -> searchHistoryTable.insertAll(entries)) + .subscribeOn(Schedulers.io()); + } + + public Single deleteSearches(final Collection entries) { + return Single.fromCallable(() -> searchHistoryTable.delete(entries)) + .subscribeOn(Schedulers.io()); + } + + public Flowable> getSearchHistory() { + return searchHistoryTable.getAll(); + } + + public Maybe onSearched(final int serviceId, final String search) { + if (!isSearchHistoryEnabled()) return Maybe.empty(); + + final Date currentTime = new Date(); + final SearchHistoryEntry newEntry = new SearchHistoryEntry(currentTime, serviceId, search); + + return Maybe.fromCallable(() -> database.runInTransaction(() -> { + SearchHistoryEntry latestEntry = searchHistoryTable.getLatestEntry(); + if (latestEntry != null && latestEntry.hasEqualValues(newEntry)) { + latestEntry.setCreationDate(currentTime); + return (long) searchHistoryTable.update(latestEntry); + } else { + return searchHistoryTable.insert(newEntry); + } + })).subscribeOn(Schedulers.io()); + } + + public Single deleteSearchHistory(final String search) { + return Single.fromCallable(() -> searchHistoryTable.deleteAllWhereQuery(search)) + .subscribeOn(Schedulers.io()); + } + + public Flowable> getRelatedSearches(final String query, + final int similarQueryLimit, + final int uniqueQueryLimit) { + return query.length() > 0 + ? searchHistoryTable.getSimilarEntries(query, similarQueryLimit) + : searchHistoryTable.getUniqueEntries(uniqueQueryLimit); + } + + private boolean isSearchHistoryEnabled() { + return sharedPreferences.getBoolean(searchHistoryKey, false); + } + + /////////////////////////////////////////////////////// + // Stream State History + /////////////////////////////////////////////////////// + + @SuppressWarnings("unused") + public Maybe loadStreamState(final StreamInfo info) { + return Maybe.fromCallable(() -> streamTable.upsert(new StreamEntity(info))) + .flatMap(streamId -> streamStateTable.getState(streamId).firstElement()) + .flatMap(states -> states.isEmpty() ? Maybe.empty() : Maybe.just(states.get(0))) + .subscribeOn(Schedulers.io()); + } + + public Maybe saveStreamState(@NonNull final StreamInfo info, final long progressTime) { + return Maybe.fromCallable(() -> database.runInTransaction(() -> { + final long streamId = streamTable.upsert(new StreamEntity(info)); + return streamStateTable.upsert(new StreamStateEntity(streamId, progressTime)); + })).subscribeOn(Schedulers.io()); + } + + /////////////////////////////////////////////////////// + // Utility + /////////////////////////////////////////////////////// + + public Single removeOrphanedRecords() { + return Single.fromCallable(streamTable::deleteOrphans).subscribeOn(Schedulers.io()); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/history/SearchHistoryFragment.java b/app/src/main/java/org/schabi/newpipe/history/SearchHistoryFragment.java index 91e2cecff..25098fac8 100644 --- a/app/src/main/java/org/schabi/newpipe/history/SearchHistoryFragment.java +++ b/app/src/main/java/org/schabi/newpipe/history/SearchHistoryFragment.java @@ -5,22 +5,30 @@ import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.StringRes; +import android.support.v7.app.AlertDialog; import android.support.v7.widget.RecyclerView; -import android.support.v7.widget.helper.ItemTouchHelper; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; -import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; -import org.schabi.newpipe.database.history.dao.HistoryDAO; import org.schabi.newpipe.database.history.model.SearchHistoryEntry; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; -public class SearchHistoryFragment extends HistoryFragment { +import java.util.Collection; +import java.util.Collections; +import java.util.List; - private static int allowedSwipeToDeleteDirections = ItemTouchHelper.RIGHT; +import io.reactivex.Flowable; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; + +public class SearchHistoryFragment extends HistoryFragment { @NonNull public static SearchHistoryFragment newInstance() { @@ -30,7 +38,6 @@ public class SearchHistoryFragment extends HistoryFragment { @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - historyItemSwipeCallback(allowedSwipeToDeleteDirections); } @NonNull @@ -39,38 +46,82 @@ public class SearchHistoryFragment extends HistoryFragment { return new SearchHistoryAdapter(getContext()); } + @Override + protected Single> insert(Collection entries) { + return historyRecordManager.insertSearches(entries); + } + + @Override + protected Single delete(Collection entries) { + return historyRecordManager.deleteSearches(entries); + } + + @NonNull + @Override + protected Flowable> getAll() { + return historyRecordManager.getSearchHistory(); + } + @StringRes @Override int getEnabledConfigKey() { return R.string.enable_search_history_key; } - @NonNull @Override - protected HistoryDAO createHistoryDAO() { - return NewPipeDatabase.getInstance().searchHistoryDAO(); + public void onHistoryItemClick(final SearchHistoryEntry historyItem) { + NavigationHelper.openSearch(getContext(), historyItem.getServiceId(), + historyItem.getSearch()); } @Override - public void onHistoryItemClick(SearchHistoryEntry historyItem) { - NavigationHelper.openSearch(getContext(), historyItem.getServiceId(), historyItem.getSearch()); + public void onHistoryItemLongClick(final SearchHistoryEntry item) { + if (activity == null) return; + + new AlertDialog.Builder(activity) + .setTitle(item.getSearch()) + .setMessage(R.string.delete_item_search_history) + .setCancelable(true) + .setNeutralButton(R.string.cancel, null) + .setPositiveButton(R.string.delete_one, (dialog, i) -> { + final Disposable onDelete = historyRecordManager + .deleteSearches(Collections.singleton(item)) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + ignored -> {/*successful*/}, + error -> Log.e(TAG, "Search history Delete One failed:", error) + ); + disposables.add(onDelete); + makeSnackbar(R.string.item_deleted); + }) + .setNegativeButton(R.string.delete_all, (dialog, i) -> { + final Disposable onDeleteAll = historyRecordManager + .deleteSearchHistory(item.getSearch()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + ignored -> {/*successful*/}, + error -> Log.e(TAG, "Search history Delete All failed:", error) + ); + disposables.add(onDeleteAll); + makeSnackbar(R.string.item_deleted); + }) + .show(); } private static class ViewHolder extends RecyclerView.ViewHolder { private final TextView search; - private final TextView time; + private final TextView info; public ViewHolder(View itemView) { super(itemView); search = itemView.findViewById(R.id.search); - time = itemView.findViewById(R.id.time); + info = itemView.findViewById(R.id.info); } } protected class SearchHistoryAdapter extends HistoryEntryAdapter { - - public SearchHistoryAdapter(Context context) { + SearchHistoryAdapter(Context context) { super(context); } @@ -84,7 +135,11 @@ public class SearchHistoryFragment extends HistoryFragment { @Override void onBindViewHolder(ViewHolder holder, SearchHistoryEntry entry, int position) { holder.search.setText(entry.getSearch()); - holder.time.setText(getFormattedDate(entry.getCreationDate())); + + final String info = Localization.concatenateStrings( + getFormattedDate(entry.getCreationDate()), + NewPipe.getNameOfService(entry.getServiceId())); + holder.info.setText(info); } } } diff --git a/app/src/main/java/org/schabi/newpipe/history/WatchHistoryFragment.java b/app/src/main/java/org/schabi/newpipe/history/WatchHistoryFragment.java new file mode 100644 index 000000000..4fe2b701d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/history/WatchHistoryFragment.java @@ -0,0 +1,171 @@ +package org.schabi.newpipe.history; + + +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.StringRes; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.RecyclerView; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import com.nostra13.universalimageloader.core.ImageLoader; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.history.model.StreamHistoryEntry; +import org.schabi.newpipe.info_list.holder.StreamInfoItemHolder; +import org.schabi.newpipe.util.ImageDisplayConstants; +import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.NavigationHelper; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import io.reactivex.Flowable; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; + + +public class WatchHistoryFragment extends HistoryFragment { + + @NonNull + public static WatchHistoryFragment newInstance() { + return new WatchHistoryFragment(); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @StringRes + @Override + int getEnabledConfigKey() { + return R.string.enable_watch_history_key; + } + + @NonNull + @Override + protected StreamHistoryAdapter createAdapter() { + return new StreamHistoryAdapter(getContext()); + } + + @Override + protected Single> insert(Collection entries) { + return historyRecordManager.insertStreamHistory(entries); + } + + @Override + protected Single delete(Collection entries) { + return historyRecordManager.deleteStreamHistory(entries); + } + + @NonNull + @Override + protected Flowable> getAll() { + return historyRecordManager.getStreamHistory(); + } + + @Override + public void onHistoryItemClick(StreamHistoryEntry historyItem) { + NavigationHelper.openVideoDetail(getContext(), historyItem.serviceId, historyItem.url, + historyItem.title); + } + + @Override + public void onHistoryItemLongClick(StreamHistoryEntry item) { + new AlertDialog.Builder(activity) + .setTitle(item.title) + .setMessage(R.string.delete_stream_history_prompt) + .setCancelable(true) + .setNeutralButton(R.string.cancel, null) + .setPositiveButton(R.string.delete_one, (dialog, i) -> { + final Disposable onDelete = historyRecordManager + .deleteStreamHistory(Collections.singleton(item)) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + ignored -> {/*successful*/}, + error -> Log.e(TAG, "Watch history Delete One failed:", error) + ); + disposables.add(onDelete); + makeSnackbar(R.string.item_deleted); + }) + .setNegativeButton(R.string.delete_all, (dialog, i) -> { + final Disposable onDeleteAll = historyRecordManager + .deleteStreamHistory(item.streamId) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + ignored -> {/*successful*/}, + error -> Log.e(TAG, "Watch history Delete All failed:", error) + ); + disposables.add(onDeleteAll); + makeSnackbar(R.string.item_deleted); + }) + .show(); + } + + private static class StreamHistoryAdapter extends HistoryEntryAdapter { + + StreamHistoryAdapter(Context context) { + super(context); + } + + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + View itemView = inflater.inflate(R.layout.list_stream_item, parent, false); + return new ViewHolder(itemView); + } + + @Override + public void onViewRecycled(ViewHolder holder) { + holder.itemView.setOnClickListener(null); + ImageLoader.getInstance() + .cancelDisplayTask(holder.thumbnailView); + } + + @Override + void onBindViewHolder(ViewHolder holder, StreamHistoryEntry entry, int position) { + final String formattedDate = getFormattedDate(entry.accessDate); + final String info; + if (entry.repeatCount > 1) { + info = Localization.concatenateStrings(formattedDate, + getFormattedViewString(entry.repeatCount)); + } else { + info = formattedDate; + } + + holder.info.setText(info); + holder.streamTitle.setText(entry.title); + holder.uploader.setText(entry.uploader); + holder.duration.setText(Localization.getDurationString(entry.duration)); + ImageLoader.getInstance().displayImage(entry.thumbnailUrl, holder.thumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS); + } + } + + private static class ViewHolder extends RecyclerView.ViewHolder { + private final TextView info; + private final TextView streamTitle; + private final ImageView thumbnailView; + private final TextView uploader; + private final TextView duration; + + public ViewHolder(View itemView) { + super(itemView); + thumbnailView = itemView.findViewById(R.id.itemThumbnailView); + info = itemView.findViewById(R.id.itemAdditionalDetails); + streamTitle = itemView.findViewById(R.id.itemVideoTitleView); + uploader = itemView.findViewById(R.id.itemUploaderView); + duration = itemView.findViewById(R.id.itemDurationView); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/history/WatchedHistoryFragment.java b/app/src/main/java/org/schabi/newpipe/history/WatchedHistoryFragment.java deleted file mode 100644 index d898bf353..000000000 --- a/app/src/main/java/org/schabi/newpipe/history/WatchedHistoryFragment.java +++ /dev/null @@ -1,116 +0,0 @@ -package org.schabi.newpipe.history; - - -import android.content.Context; -import android.os.Bundle; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.annotation.StringRes; -import android.support.v7.widget.RecyclerView; -import android.support.v7.widget.helper.ItemTouchHelper; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import com.nostra13.universalimageloader.core.ImageLoader; - -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.history.dao.HistoryDAO; -import org.schabi.newpipe.database.history.model.WatchHistoryEntry; -import org.schabi.newpipe.info_list.holder.StreamInfoItemHolder; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; - - -public class WatchedHistoryFragment extends HistoryFragment { - - private static int allowedSwipeToDeleteDirections = ItemTouchHelper.LEFT; - - @NonNull - public static WatchedHistoryFragment newInstance() { - return new WatchedHistoryFragment(); - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - historyItemSwipeCallback(allowedSwipeToDeleteDirections); - } - - @StringRes - @Override - int getEnabledConfigKey() { - return R.string.enable_watch_history_key; - } - - @NonNull - @Override - protected WatchedHistoryAdapter createAdapter() { - return new WatchedHistoryAdapter(getContext()); - } - - @NonNull - @Override - protected HistoryDAO createHistoryDAO() { - return NewPipeDatabase.getInstance().watchHistoryDAO(); - } - - @Override - public void onHistoryItemClick(WatchHistoryEntry historyItem) { - NavigationHelper.openVideoDetail(getContext(), - historyItem.getServiceId(), - historyItem.getUrl(), - historyItem.getTitle()); - } - - private static class WatchedHistoryAdapter extends HistoryEntryAdapter { - - public WatchedHistoryAdapter(Context context) { - super(context); - } - - @Override - public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - LayoutInflater inflater = LayoutInflater.from(parent.getContext()); - View itemView = inflater.inflate(R.layout.list_stream_item, parent, false); - return new ViewHolder(itemView); - } - - @Override - public void onViewRecycled(ViewHolder holder) { - holder.itemView.setOnClickListener(null); - ImageLoader.getInstance() - .cancelDisplayTask(holder.thumbnailView); - } - - @Override - void onBindViewHolder(ViewHolder holder, WatchHistoryEntry entry, int position) { - holder.date.setText(getFormattedDate(entry.getCreationDate())); - holder.streamTitle.setText(entry.getTitle()); - holder.uploader.setText(entry.getUploader()); - holder.duration.setText(Localization.getDurationString(entry.getDuration())); - ImageLoader.getInstance() - .displayImage(entry.getThumbnailURL(), holder.thumbnailView, StreamInfoItemHolder.DISPLAY_THUMBNAIL_OPTIONS); - } - } - - private static class ViewHolder extends RecyclerView.ViewHolder { - private final TextView date; - private final TextView streamTitle; - private final ImageView thumbnailView; - private final TextView uploader; - private final TextView duration; - - public ViewHolder(View itemView) { - super(itemView); - thumbnailView = itemView.findViewById(R.id.itemThumbnailView); - date = itemView.findViewById(R.id.itemAdditionalDetails); - streamTitle = itemView.findViewById(R.id.itemVideoTitleView); - uploader = itemView.findViewById(R.id.itemUploaderView); - duration = itemView.findViewById(R.id.itemDurationView); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java index ab3d73149..78867c81f 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java @@ -16,8 +16,10 @@ import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder; import org.schabi.newpipe.info_list.holder.InfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder; +import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder; import org.schabi.newpipe.info_list.holder.StreamInfoItemHolder; import org.schabi.newpipe.info_list.holder.StreamMiniInfoItemHolder; +import org.schabi.newpipe.util.OnClickGesture; /* * Created by Christian Schabesberger on 26.09.16. @@ -42,17 +44,12 @@ import org.schabi.newpipe.info_list.holder.StreamMiniInfoItemHolder; public class InfoItemBuilder { private static final String TAG = InfoItemBuilder.class.toString(); - public interface OnInfoItemSelectedListener { - void selected(T selectedItem); - void held(T selectedItem); - } - private final Context context; private ImageLoader imageLoader = ImageLoader.getInstance(); - private OnInfoItemSelectedListener onStreamSelectedListener; - private OnInfoItemSelectedListener onChannelSelectedListener; - private OnInfoItemSelectedListener onPlaylistSelectedListener; + private OnClickGesture onStreamSelectedListener; + private OnClickGesture onChannelSelectedListener; + private OnClickGesture onPlaylistSelectedListener; public InfoItemBuilder(Context context) { this.context = context; @@ -63,7 +60,7 @@ public class InfoItemBuilder { } public View buildView(@NonNull ViewGroup parent, @NonNull final InfoItem infoItem, boolean useMiniVariant) { - InfoItemHolder holder = holderFromInfoType(parent, infoItem.info_type, useMiniVariant); + InfoItemHolder holder = holderFromInfoType(parent, infoItem.getInfoType(), useMiniVariant); holder.updateFromItem(infoItem); return holder.itemView; } @@ -75,7 +72,7 @@ public class InfoItemBuilder { case CHANNEL: return useMiniVariant ? new ChannelMiniInfoItemHolder(this, parent) : new ChannelInfoItemHolder(this, parent); case PLAYLIST: - return new PlaylistInfoItemHolder(this, parent); + return useMiniVariant ? new PlaylistMiniInfoItemHolder(this, parent) : new PlaylistInfoItemHolder(this, parent); default: Log.e(TAG, "Trollolo"); throw new RuntimeException("InfoType not expected = " + infoType.name()); @@ -90,27 +87,27 @@ public class InfoItemBuilder { return imageLoader; } - public OnInfoItemSelectedListener getOnStreamSelectedListener() { + public OnClickGesture getOnStreamSelectedListener() { return onStreamSelectedListener; } - public void setOnStreamSelectedListener(OnInfoItemSelectedListener listener) { + public void setOnStreamSelectedListener(OnClickGesture listener) { this.onStreamSelectedListener = listener; } - public OnInfoItemSelectedListener getOnChannelSelectedListener() { + public OnClickGesture getOnChannelSelectedListener() { return onChannelSelectedListener; } - public void setOnChannelSelectedListener(OnInfoItemSelectedListener listener) { + public void setOnChannelSelectedListener(OnClickGesture listener) { this.onChannelSelectedListener = listener; } - public OnInfoItemSelectedListener getOnPlaylistSelectedListener() { + public OnClickGesture getOnPlaylistSelectedListener() { return onPlaylistSelectedListener; } - public void setOnPlaylistSelectedListener(OnInfoItemSelectedListener listener) { + public void setOnPlaylistSelectedListener(OnClickGesture listener) { this.onPlaylistSelectedListener = listener; } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemDialog.java index cdb2191e5..88aa76887 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemDialog.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemDialog.java @@ -19,7 +19,7 @@ public class InfoItemDialog { @NonNull final StreamInfoItem info, @NonNull final String[] commands, @NonNull final DialogInterface.OnClickListener actions) { - this(activity, commands, actions, info.getName(), info.uploader_name); + this(activity, commands, actions, info.getName(), info.getUploaderName()); } public InfoItemDialog(@NonNull final Activity activity, @@ -28,8 +28,7 @@ public class InfoItemDialog { @NonNull final String title, @Nullable final String additionalDetail) { - final LayoutInflater inflater = activity.getLayoutInflater(); - final View bannerView = inflater.inflate(R.layout.dialog_title, null); + final View bannerView = View.inflate(activity, R.layout.dialog_title, null); bannerView.setSelected(true); TextView titleView = bannerView.findViewById(R.id.itemTitleView); diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java index 806b348d7..9b3405484 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java @@ -10,13 +10,14 @@ import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.info_list.InfoItemBuilder.OnInfoItemSelectedListener; import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder; import org.schabi.newpipe.info_list.holder.InfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder; +import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder; import org.schabi.newpipe.info_list.holder.StreamInfoItemHolder; import org.schabi.newpipe.info_list.holder.StreamMiniInfoItemHolder; +import org.schabi.newpipe.util.OnClickGesture; import java.util.ArrayList; import java.util.List; @@ -52,6 +53,7 @@ public class InfoListAdapter extends RecyclerView.Adapter(); } - public void setOnStreamSelectedListener(OnInfoItemSelectedListener listener) { + public void setOnStreamSelectedListener(OnClickGesture listener) { infoItemBuilder.setOnStreamSelectedListener(listener); } - public void setOnChannelSelectedListener(OnInfoItemSelectedListener listener) { + public void setOnChannelSelectedListener(OnClickGesture listener) { infoItemBuilder.setOnChannelSelectedListener(listener); } - public void setOnPlaylistSelectedListener(OnInfoItemSelectedListener listener) { + public void setOnPlaylistSelectedListener(OnClickGesture listener) { infoItemBuilder.setOnPlaylistSelectedListener(listener); } @@ -200,14 +202,14 @@ public class InfoListAdapter extends RecyclerView.Adapter= 0) { - String formattedVideoAmount = Localization.localizeStreamCount(itemBuilder.getContext(), item.stream_count); + if (item.getStreamCount() >= 0) { + String formattedVideoAmount = Localization.localizeStreamCount(itemBuilder.getContext(), + item.getStreamCount()); if (!details.isEmpty()) { details += " • " + formattedVideoAmount; diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java index 48fb18517..643886da8 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java @@ -1,15 +1,13 @@ package org.schabi.newpipe.info_list.holder; -import android.view.View; import android.view.ViewGroup; import android.widget.TextView; -import com.nostra13.universalimageloader.core.DisplayImageOptions; - import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; +import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.Localization; import de.hdodenhof.circleimageview.CircleImageView; @@ -40,34 +38,23 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder { itemAdditionalDetailView.setText(getDetailLine(item)); itemBuilder.getImageLoader() - .displayImage(item.thumbnail_url, itemThumbnailView, ChannelInfoItemHolder.DISPLAY_THUMBNAIL_OPTIONS); + .displayImage(item.getThumbnailUrl(), + itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS); - itemView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - if (itemBuilder.getOnChannelSelectedListener() != null) { - itemBuilder.getOnChannelSelectedListener().selected(item); - } + itemView.setOnClickListener(view -> { + if (itemBuilder.getOnChannelSelectedListener() != null) { + itemBuilder.getOnChannelSelectedListener().selected(item); } }); } protected String getDetailLine(final ChannelInfoItem item) { String details = ""; - if (item.subscriber_count >= 0) { - details += Localization.shortSubscriberCount(itemBuilder.getContext(), item.subscriber_count); + if (item.getSubscriberCount() >= 0) { + details += Localization.shortSubscriberCount(itemBuilder.getContext(), + item.getSubscriberCount()); } return details; } - - /** - * Display options for channel thumbnails - */ - public static final DisplayImageOptions DISPLAY_THUMBNAIL_OPTIONS = - new DisplayImageOptions.Builder() - .cloneFrom(BASE_DISPLAY_IMAGE_OPTIONS) - .showImageOnLoading(R.drawable.buddy_channel_item) - .showImageForEmptyUri(R.drawable.buddy_channel_item) - .showImageOnFail(R.drawable.buddy_channel_item) - .build(); } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.java index fb5aa2b7c..ebb5b4114 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.java @@ -4,8 +4,6 @@ import android.support.v7.widget.RecyclerView; import android.view.LayoutInflater; import android.view.ViewGroup; -import com.nostra13.universalimageloader.core.DisplayImageOptions; - import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; @@ -38,16 +36,4 @@ public abstract class InfoItemHolder extends RecyclerView.ViewHolder { } public abstract void updateFromItem(final InfoItem infoItem); - - /*////////////////////////////////////////////////////////////////////////// - // ImageLoaderOptions - //////////////////////////////////////////////////////////////////////////*/ - - /** - * Base display options - */ - public static final DisplayImageOptions BASE_DISPLAY_IMAGE_OPTIONS = - new DisplayImageOptions.Builder() - .cacheInMemory(true) - .build(); } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistInfoItemHolder.java index 9c12c5e4d..252d05e09 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistInfoItemHolder.java @@ -1,62 +1,13 @@ package org.schabi.newpipe.info_list.holder; -import android.view.View; import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import com.nostra13.universalimageloader.core.DisplayImageOptions; import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; -public class PlaylistInfoItemHolder extends InfoItemHolder { - public final ImageView itemThumbnailView; - public final TextView itemStreamCountView; - public final TextView itemTitleView; - public final TextView itemUploaderView; +public class PlaylistInfoItemHolder extends PlaylistMiniInfoItemHolder { public PlaylistInfoItemHolder(InfoItemBuilder infoItemBuilder, ViewGroup parent) { super(infoItemBuilder, R.layout.list_playlist_item, parent); - - itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); - itemTitleView = itemView.findViewById(R.id.itemTitleView); - itemStreamCountView = itemView.findViewById(R.id.itemStreamCountView); - itemUploaderView = itemView.findViewById(R.id.itemUploaderView); } - - @Override - public void updateFromItem(final InfoItem infoItem) { - if (!(infoItem instanceof PlaylistInfoItem)) return; - final PlaylistInfoItem item = (PlaylistInfoItem) infoItem; - - itemTitleView.setText(item.getName()); - itemStreamCountView.setText(item.stream_count + ""); - itemUploaderView.setText(item.uploader_name); - - itemBuilder.getImageLoader() - .displayImage(item.thumbnail_url, itemThumbnailView, DISPLAY_THUMBNAIL_OPTIONS); - - itemView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - if (itemBuilder.getOnPlaylistSelectedListener() != null) { - itemBuilder.getOnPlaylistSelectedListener().selected(item); - } - } - }); - } - - /** - * Display options for playlist thumbnails - */ - public static final DisplayImageOptions DISPLAY_THUMBNAIL_OPTIONS = - new DisplayImageOptions.Builder() - .cloneFrom(BASE_DISPLAY_IMAGE_OPTIONS) - .showImageOnLoading(R.drawable.dummy_thumbnail_playlist) - .showImageForEmptyUri(R.drawable.dummy_thumbnail_playlist) - .showImageOnFail(R.drawable.dummy_thumbnail_playlist) - .build(); } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java new file mode 100644 index 000000000..b6bd2f389 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java @@ -0,0 +1,59 @@ +package org.schabi.newpipe.info_list.holder; + +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; +import org.schabi.newpipe.info_list.InfoItemBuilder; +import org.schabi.newpipe.util.ImageDisplayConstants; + +public class PlaylistMiniInfoItemHolder extends InfoItemHolder { + public final ImageView itemThumbnailView; + public final TextView itemStreamCountView; + public final TextView itemTitleView; + public final TextView itemUploaderView; + + public PlaylistMiniInfoItemHolder(InfoItemBuilder infoItemBuilder, int layoutId, ViewGroup parent) { + super(infoItemBuilder, layoutId, parent); + + itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); + itemTitleView = itemView.findViewById(R.id.itemTitleView); + itemStreamCountView = itemView.findViewById(R.id.itemStreamCountView); + itemUploaderView = itemView.findViewById(R.id.itemUploaderView); + } + + public PlaylistMiniInfoItemHolder(InfoItemBuilder infoItemBuilder, ViewGroup parent) { + this(infoItemBuilder, R.layout.list_playlist_mini_item, parent); + } + + @Override + public void updateFromItem(final InfoItem infoItem) { + if (!(infoItem instanceof PlaylistInfoItem)) return; + final PlaylistInfoItem item = (PlaylistInfoItem) infoItem; + + itemTitleView.setText(item.getName()); + itemStreamCountView.setText(String.valueOf(item.getStreamCount())); + itemUploaderView.setText(item.getUploaderName()); + + itemBuilder.getImageLoader() + .displayImage(item.getThumbnailUrl(), itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS); + + itemView.setOnClickListener(view -> { + if (itemBuilder.getOnPlaylistSelectedListener() != null) { + itemBuilder.getOnPlaylistSelectedListener().selected(item); + } + }); + + itemView.setLongClickable(true); + itemView.setOnLongClickListener(view -> { + if (itemBuilder.getOnPlaylistSelectedListener() != null) { + itemBuilder.getOnPlaylistSelectedListener().held(item); + } + return true; + }); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java index 78954a2ee..0a7705427 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java @@ -51,14 +51,14 @@ public class StreamInfoItemHolder extends StreamMiniInfoItemHolder { private String getStreamInfoDetailLine(final StreamInfoItem infoItem) { String viewsAndDate = ""; - if (infoItem.view_count >= 0) { - viewsAndDate = Localization.shortViewCount(itemBuilder.getContext(), infoItem.view_count); + if (infoItem.getViewCount() >= 0) { + viewsAndDate = Localization.shortViewCount(itemBuilder.getContext(), infoItem.getViewCount()); } - if (!TextUtils.isEmpty(infoItem.upload_date)) { + if (!TextUtils.isEmpty(infoItem.getUploadDate())) { if (viewsAndDate.isEmpty()) { - viewsAndDate = infoItem.upload_date; + viewsAndDate = infoItem.getUploadDate(); } else { - viewsAndDate += " • " + infoItem.upload_date; + viewsAndDate += " • " + infoItem.getUploadDate(); } } return viewsAndDate; diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java index 48dc470d0..048b907af 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java @@ -6,13 +6,12 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; -import com.nostra13.universalimageloader.core.DisplayImageOptions; - import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.info_list.InfoItemBuilder; +import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.Localization; public class StreamMiniInfoItemHolder extends InfoItemHolder { @@ -41,15 +40,17 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { final StreamInfoItem item = (StreamInfoItem) infoItem; itemVideoTitleView.setText(item.getName()); - itemUploaderView.setText(item.uploader_name); + itemUploaderView.setText(item.getUploaderName()); - if (item.duration > 0) { - itemDurationView.setText(Localization.getDurationString(item.duration)); - itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), R.color.duration_background_color)); + if (item.getDuration() > 0) { + itemDurationView.setText(Localization.getDurationString(item.getDuration())); + itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), + R.color.duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); - } else if (item.stream_type == StreamType.LIVE_STREAM) { + } else if (item.getStreamType() == StreamType.LIVE_STREAM) { itemDurationView.setText(R.string.duration_live); - itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), R.color.live_duration_background_color)); + itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), + R.color.live_duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); } else { itemDurationView.setVisibility(View.GONE); @@ -57,25 +58,24 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { // Default thumbnail is shown on error, while loading and if the url is empty itemBuilder.getImageLoader() - .displayImage(item.thumbnail_url, itemThumbnailView, StreamInfoItemHolder.DISPLAY_THUMBNAIL_OPTIONS); + .displayImage(item.getThumbnailUrl(), + itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS); - itemView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - if (itemBuilder.getOnStreamSelectedListener() != null) { - itemBuilder.getOnStreamSelectedListener().selected(item); - } + itemView.setOnClickListener(view -> { + if (itemBuilder.getOnStreamSelectedListener() != null) { + itemBuilder.getOnStreamSelectedListener().selected(item); } }); - switch (item.stream_type) { + switch (item.getStreamType()) { case AUDIO_STREAM: case VIDEO_STREAM: - case FILE: - enableLongClick(item); - break; case LIVE_STREAM: case AUDIO_LIVE_STREAM: + enableLongClick(item); + break; + case FILE: case NONE: default: disableLongClick(); @@ -85,14 +85,11 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { private void enableLongClick(final StreamInfoItem item) { itemView.setLongClickable(true); - itemView.setOnLongClickListener(new View.OnLongClickListener() { - @Override - public boolean onLongClick(View view) { - if (itemBuilder.getOnStreamSelectedListener() != null) { - itemBuilder.getOnStreamSelectedListener().held(item); - } - return true; + itemView.setOnLongClickListener(view -> { + if (itemBuilder.getOnStreamSelectedListener() != null) { + itemBuilder.getOnStreamSelectedListener().held(item); } + return true; }); } @@ -100,15 +97,4 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { itemView.setLongClickable(false); itemView.setOnLongClickListener(null); } - - /** - * Display options for stream thumbnails - */ - public static final DisplayImageOptions DISPLAY_THUMBNAIL_OPTIONS = - new DisplayImageOptions.Builder() - .cloneFrom(BASE_DISPLAY_IMAGE_OPTIONS) - .showImageOnFail(R.drawable.dummy_thumbnail) - .showImageForEmptyUri(R.drawable.dummy_thumbnail) - .showImageOnLoading(R.drawable.dummy_thumbnail) - .build(); } diff --git a/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java index fd47a7167..ac070fb44 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BackgroundPlayer.java @@ -33,6 +33,7 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.app.NotificationCompat; import android.util.Log; +import android.view.View; import android.widget.RemoteViews; import com.google.android.exoplayer2.PlaybackParameters; @@ -46,6 +47,7 @@ import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.player.event.PlayerEventListener; import org.schabi.newpipe.player.helper.LockManager; +import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.playlist.PlayQueueItem; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; @@ -75,6 +77,7 @@ public final class BackgroundPlayer extends Service { private BasePlayerImpl basePlayerImpl; private LockManager lockManager; + /*////////////////////////////////////////////////////////////////////////// // Service-Activity Binder //////////////////////////////////////////////////////////////////////////*/ @@ -291,15 +294,15 @@ public final class BackgroundPlayer extends Service { } @Override - public void onThumbnailReceived(Bitmap thumbnail) { - super.onThumbnailReceived(thumbnail); + public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) { + super.onLoadingComplete(imageUri, view, loadedImage); - if (thumbnail != null) { + if (loadedImage != null) { // rebuild notification here since remote view does not release bitmaps, causing memory leaks resetNotification(); - if (notRemoteView != null) notRemoteView.setImageViewBitmap(R.id.notificationCover, thumbnail); - if (bigNotRemoteView != null) bigNotRemoteView.setImageViewBitmap(R.id.notificationCover, thumbnail); + if (notRemoteView != null) notRemoteView.setImageViewBitmap(R.id.notificationCover, loadedImage); + if (bigNotRemoteView != null) bigNotRemoteView.setImageViewBitmap(R.id.notificationCover, loadedImage); updateNotification(-1); } @@ -378,29 +381,34 @@ public final class BackgroundPlayer extends Service { // Playback Listener //////////////////////////////////////////////////////////////////////////*/ - @Override - public void sync(@NonNull final PlayQueueItem item, @Nullable final StreamInfo info) { - if (currentItem == item && currentInfo == info) return; - super.sync(item, info); - - resetNotification(); - updateNotification(-1); - updateMetadata(); + protected void onMetadataChanged(@NonNull final PlayQueueItem item, + @Nullable final StreamInfo info, + final int newPlayQueueIndex, + final boolean hasPlayQueueItemChanged) { + if (shouldUpdateOnProgress || hasPlayQueueItemChanged) { + resetNotification(); + updateNotification(-1); + updateMetadata(); + } } @Override @Nullable public MediaSource sourceOf(final PlayQueueItem item, final StreamInfo info) { - final int index = ListHelper.getDefaultAudioFormat(context, info.audio_streams); - if (index < 0 || index >= info.audio_streams.size()) return null; + final MediaSource liveSource = super.sourceOf(item, info); + if (liveSource != null) return liveSource; - final AudioStream audio = info.audio_streams.get(index); - return buildMediaSource(audio.getUrl(), MediaFormat.getSuffixById(audio.getFormatId())); + final int index = ListHelper.getDefaultAudioFormat(context, info.getAudioStreams()); + if (index < 0 || index >= info.getAudioStreams().size()) return null; + + final AudioStream audio = info.getAudioStreams().get(index); + return buildMediaSource(audio.getUrl(), PlayerHelper.cacheKeyOf(info, audio), + MediaFormat.getSuffixById(audio.getFormatId())); } @Override - public void shutdown() { - super.shutdown(); + public void onPlaybackShutdown() { + super.onPlaybackShutdown(); onClose(); } @@ -429,7 +437,8 @@ public final class BackgroundPlayer extends Service { private void updatePlayback() { if (activityListener != null && simpleExoPlayer != null && playQueue != null) { - activityListener.onPlaybackUpdate(currentState, getRepeatMode(), playQueue.isShuffled(), getPlaybackParameters()); + activityListener.onPlaybackUpdate(currentState, getRepeatMode(), + playQueue.isShuffled(), getPlaybackParameters()); } } @@ -477,7 +486,7 @@ public final class BackgroundPlayer extends Service { onClose(); break; case ACTION_PLAY_PAUSE: - onVideoPlayPause(); + onPlayPause(); break; case ACTION_REPEAT: onRepeatClicked(); diff --git a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java index 55a73d484..cd1451d37 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -43,44 +43,49 @@ import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; -import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.source.BehindLiveWindowException; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.source.dash.DashMediaSource; -import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource; -import com.google.android.exoplayer2.source.hls.HlsMediaSource; -import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource; -import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; -import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; import com.google.android.exoplayer2.util.Util; import com.nostra13.universalimageloader.core.ImageLoader; -import com.nostra13.universalimageloader.core.listener.SimpleImageLoadingListener; +import com.nostra13.universalimageloader.core.assist.FailReason; +import com.nostra13.universalimageloader.core.listener.ImageLoadingListener; +import org.schabi.newpipe.Downloader; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipe.history.HistoryRecordManager; import org.schabi.newpipe.player.helper.AudioReactor; -import org.schabi.newpipe.player.helper.CacheFactory; import org.schabi.newpipe.player.helper.LoadController; +import org.schabi.newpipe.player.helper.MediaSessionManager; +import org.schabi.newpipe.player.helper.PlayerDataSource; +import org.schabi.newpipe.player.helper.PlayerHelper; +import org.schabi.newpipe.player.playback.BasePlayerMediaSession; +import org.schabi.newpipe.player.playback.CustomTrackSelector; import org.schabi.newpipe.player.playback.MediaSourceManager; import org.schabi.newpipe.player.playback.PlaybackListener; import org.schabi.newpipe.playlist.PlayQueue; import org.schabi.newpipe.playlist.PlayQueueAdapter; import org.schabi.newpipe.playlist.PlayQueueItem; +import org.schabi.newpipe.util.SerializedCache; -import java.io.Serializable; +import java.io.IOException; +import java.net.UnknownHostException; import java.util.concurrent.TimeUnit; import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.Disposable; -import io.reactivex.functions.Consumer; -import io.reactivex.functions.Predicate; +import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_INTERNAL; +import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_PERIOD_TRANSITION; +import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK; +import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT; import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; /** @@ -89,17 +94,18 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; * @author mauriciocolli */ @SuppressWarnings({"WeakerAccess"}) -public abstract class BasePlayer implements Player.EventListener, PlaybackListener { +public abstract class BasePlayer implements + Player.EventListener, PlaybackListener, ImageLoadingListener { public static final boolean DEBUG = true; - public static final String TAG = "BasePlayer"; + @NonNull public static final String TAG = "BasePlayer"; - protected Context context; + @NonNull final protected Context context; - protected BroadcastReceiver broadcastReceiver; - protected IntentFilter intentFilter; + @NonNull final protected BroadcastReceiver broadcastReceiver; + @NonNull final protected IntentFilter intentFilter; - protected PlayQueueAdapter playQueueAdapter; + @NonNull final protected HistoryRecordManager recordManager; /*////////////////////////////////////////////////////////////////////////// // Intent @@ -109,7 +115,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen public static final String PLAYBACK_PITCH = "playback_pitch"; public static final String PLAYBACK_SPEED = "playback_speed"; public static final String PLAYBACK_QUALITY = "playback_quality"; - public static final String PLAY_QUEUE = "play_queue"; + public static final String PLAY_QUEUE_KEY = "play_queue_key"; public static final String APPEND_ONLY = "append_only"; public static final String SELECT_ON_APPEND = "select_on_append"; @@ -120,8 +126,10 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen protected static final float[] PLAYBACK_SPEEDS = {0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f}; protected static final float[] PLAYBACK_PITCHES = {0.8f, 0.9f, 0.95f, 1f, 1.05f, 1.1f, 1.2f}; - protected MediaSourceManager playbackManager; protected PlayQueue playQueue; + protected PlayQueueAdapter playQueueAdapter; + + protected MediaSourceManager playbackManager; protected StreamInfo currentInfo; protected PlayQueueItem currentItem; @@ -137,20 +145,22 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen protected final static int PROGRESS_LOOP_INTERVAL = 500; protected final static int RECOVERY_SKIP_THRESHOLD = 3000; // 3 seconds + protected CustomTrackSelector trackSelector; + protected PlayerDataSource dataSource; + protected SimpleExoPlayer simpleExoPlayer; protected AudioReactor audioReactor; + protected MediaSessionManager mediaSessionManager; - protected boolean isPrepared = false; - - protected DefaultTrackSelector trackSelector; - protected DataSource.Factory cacheDataSourceFactory; - protected DefaultExtractorsFactory extractorsFactory; + private boolean isPrepared = false; + private boolean isSynchronizing = false; protected Disposable progressUpdateReactor; + protected CompositeDisposable databaseUpdateReactor; //////////////////////////////////////////////////////////////////////////*/ - public BasePlayer(Context context) { + public BasePlayer(@NonNull final Context context) { this.context = context; this.broadcastReceiver = new BroadcastReceiver() { @@ -162,6 +172,8 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen this.intentFilter = new IntentFilter(); setupBroadcastReceiver(intentFilter); context.registerReceiver(broadcastReceiver, intentFilter); + + this.recordManager = new HistoryRecordManager(context); } public void setup() { @@ -172,57 +184,48 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen public void initPlayer() { if (DEBUG) Log.d(TAG, "initPlayer() called with: context = [" + context + "]"); + if (databaseUpdateReactor != null) databaseUpdateReactor.dispose(); + databaseUpdateReactor = new CompositeDisposable(); + + final String userAgent = Downloader.USER_AGENT; final DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter(); - final AdaptiveTrackSelection.Factory trackSelectionFactory = new AdaptiveTrackSelection.Factory(bandwidthMeter); + dataSource = new PlayerDataSource(context, userAgent, bandwidthMeter); + + final AdaptiveTrackSelection.Factory trackSelectionFactory = + new AdaptiveTrackSelection.Factory(bandwidthMeter); + trackSelector = new CustomTrackSelector(trackSelectionFactory); + final LoadControl loadControl = new LoadController(context); final RenderersFactory renderFactory = new DefaultRenderersFactory(context); - - trackSelector = new DefaultTrackSelector(trackSelectionFactory); - extractorsFactory = new DefaultExtractorsFactory(); - cacheDataSourceFactory = new CacheFactory(context); - simpleExoPlayer = ExoPlayerFactory.newSimpleInstance(renderFactory, trackSelector, loadControl); - audioReactor = new AudioReactor(context, simpleExoPlayer); - simpleExoPlayer.addListener(this); simpleExoPlayer.setPlayWhenReady(true); + simpleExoPlayer.setSeekParameters(PlayerHelper.getSeekParameters(context)); + + audioReactor = new AudioReactor(context, simpleExoPlayer); + mediaSessionManager = new MediaSessionManager(context, simpleExoPlayer, + new BasePlayerMediaSession(this)); } public void initListeners() {} - private Disposable getProgressReactor() { - return Observable.interval(PROGRESS_LOOP_INTERVAL, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .filter(new Predicate() { - @Override - public boolean test(Long aLong) throws Exception { - return isProgressLoopRunning(); - } - }) - .subscribe(new Consumer() { - @Override - public void accept(Long aLong) throws Exception { - triggerProgressUpdate(); - } - }); - } - public void handleIntent(Intent intent) { if (DEBUG) Log.d(TAG, "handleIntent() called with: intent = [" + intent + "]"); if (intent == null) return; // Resolve play queue - if (!intent.hasExtra(PLAY_QUEUE)) return; - final Serializable playQueueCandidate = intent.getSerializableExtra(PLAY_QUEUE); - if (!(playQueueCandidate instanceof PlayQueue)) return; - final PlayQueue queue = (PlayQueue) playQueueCandidate; + if (!intent.hasExtra(PLAY_QUEUE_KEY)) return; + final String intentCacheKey = intent.getStringExtra(PLAY_QUEUE_KEY); + final PlayQueue queue = SerializedCache.getInstance().take(intentCacheKey, PlayQueue.class); + if (queue == null) return; // Resolve append intents if (intent.getBooleanExtra(APPEND_ONLY, false) && playQueue != null) { int sizeBeforeAppend = playQueue.size(); playQueue.append(queue.getStreams()); - if (intent.getBooleanExtra(SELECT_ON_APPEND, false) && queue.getStreams().size() > 0) { + if (intent.getBooleanExtra(SELECT_ON_APPEND, false) && + queue.getStreams().size() > 0) { playQueue.setIndex(sizeBeforeAppend); } @@ -233,43 +236,28 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen final float playbackSpeed = intent.getFloatExtra(PLAYBACK_SPEED, getPlaybackSpeed()); final float playbackPitch = intent.getFloatExtra(PLAYBACK_PITCH, getPlaybackPitch()); - // Re-initialization + // Good to go... + initPlayback(queue, repeatMode, playbackSpeed, playbackPitch); + } + + protected void initPlayback(@NonNull final PlayQueue queue, + @Player.RepeatMode final int repeatMode, + final float playbackSpeed, + final float playbackPitch) { destroyPlayer(); initPlayer(); setRepeatMode(repeatMode); setPlaybackParameters(playbackSpeed, playbackPitch); - // Good to go... - initPlayback(queue); - } - - protected void initPlayback(final PlayQueue queue) { playQueue = queue; playQueue.init(); + if (playbackManager != null) playbackManager.dispose(); playbackManager = new MediaSourceManager(this, playQueue); if (playQueueAdapter != null) playQueueAdapter.dispose(); playQueueAdapter = new PlayQueueAdapter(context, playQueue); } - public void initThumbnail(final String url) { - if (DEBUG) Log.d(TAG, "initThumbnail() called"); - if (url == null || url.isEmpty()) return; - ImageLoader.getInstance().resume(); - ImageLoader.getInstance().loadImage(url, new SimpleImageLoadingListener() { - @Override - public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) { - if (simpleExoPlayer == null) return; - if (DEBUG) Log.d(TAG, "onLoadingComplete() called with: imageUri = [" + imageUri + "], view = [" + view + "], loadedImage = [" + loadedImage + "]"); - onThumbnailReceived(loadedImage); - } - }); - } - - public void onThumbnailReceived(Bitmap thumbnail) { - if (DEBUG) Log.d(TAG, "onThumbnailReceived() called with: thumbnail = [" + thumbnail + "]"); - } - public void destroyPlayer() { if (DEBUG) Log.d(TAG, "destroyPlayer() called"); if (simpleExoPlayer != null) { @@ -279,45 +267,112 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen } if (isProgressLoopRunning()) stopProgressLoop(); if (playQueue != null) playQueue.dispose(); + if (audioReactor != null) audioReactor.dispose(); if (playbackManager != null) playbackManager.dispose(); - if (audioReactor != null) audioReactor.abandonAudioFocus(); + if (databaseUpdateReactor != null) databaseUpdateReactor.dispose(); + + if (playQueueAdapter != null) { + playQueueAdapter.unsetSelectedListener(); + playQueueAdapter.dispose(); + } } public void destroy() { if (DEBUG) Log.d(TAG, "destroy() called"); destroyPlayer(); - clearThumbnailCache(); unregisterBroadcastReceiver(); trackSelector = null; simpleExoPlayer = null; + mediaSessionManager = null; } - public MediaSource buildMediaSource(String url, String overrideExtension) { + /*////////////////////////////////////////////////////////////////////////// + // Thumbnail Loading + //////////////////////////////////////////////////////////////////////////*/ + + public void initThumbnail(final String url) { + if (DEBUG) Log.d(TAG, "Thumbnail - initThumbnail() called"); + if (url == null || url.isEmpty()) return; + ImageLoader.getInstance().resume(); + ImageLoader.getInstance().loadImage(url, this); + } + + @Override + public void onLoadingStarted(String imageUri, View view) { + if (DEBUG) Log.d(TAG, "Thumbnail - onLoadingStarted() called on: " + + "imageUri = [" + imageUri + "], view = [" + view + "]"); + } + + @Override + public void onLoadingFailed(String imageUri, View view, FailReason failReason) { + Log.e(TAG, "Thumbnail - onLoadingFailed() called on imageUri = [" + imageUri + "]", + failReason.getCause()); + } + + @Override + public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) { + if (DEBUG) Log.d(TAG, "Thumbnail - onLoadingComplete() called with: " + + "imageUri = [" + imageUri + "], view = [" + view + "], " + + "loadedImage = [" + loadedImage + "]"); + } + + @Override + public void onLoadingCancelled(String imageUri, View view) { + if (DEBUG) Log.d(TAG, "Thumbnail - onLoadingCancelled() called with: " + + "imageUri = [" + imageUri + "], view = [" + view + "]"); + } + /*////////////////////////////////////////////////////////////////////////// + // MediaSource Building + //////////////////////////////////////////////////////////////////////////*/ + + public MediaSource buildLiveMediaSource(@NonNull final String sourceUrl, + @C.ContentType final int type) { if (DEBUG) { - Log.d(TAG, "buildMediaSource() called with: url = [" + url + "], overrideExtension = [" + overrideExtension + "]"); + Log.d(TAG, "buildLiveMediaSource() called with: url = [" + sourceUrl + + "], content type = [" + type + "]"); } - Uri uri = Uri.parse(url); - int type = TextUtils.isEmpty(overrideExtension) ? Util.inferContentType(uri) : Util.inferContentType("." + overrideExtension); - MediaSource mediaSource; + if (dataSource == null) return null; + + final Uri uri = Uri.parse(sourceUrl); switch (type) { case C.TYPE_SS: - mediaSource = new SsMediaSource(uri, cacheDataSourceFactory, new DefaultSsChunkSource.Factory(cacheDataSourceFactory), null, null); - break; + return dataSource.getLiveSsMediaSourceFactory().createMediaSource(uri); case C.TYPE_DASH: - mediaSource = new DashMediaSource(uri, cacheDataSourceFactory, new DefaultDashChunkSource.Factory(cacheDataSourceFactory), null, null); - break; + return dataSource.getLiveDashMediaSourceFactory().createMediaSource(uri); case C.TYPE_HLS: - mediaSource = new HlsMediaSource(uri, cacheDataSourceFactory, null, null); - break; - case C.TYPE_OTHER: - mediaSource = new ExtractorMediaSource(uri, cacheDataSourceFactory, extractorsFactory, null, null); - break; - default: { + return dataSource.getLiveHlsMediaSourceFactory().createMediaSource(uri); + default: + throw new IllegalStateException("Unsupported type: " + type); + } + } + + public MediaSource buildMediaSource(@NonNull final String sourceUrl, + @NonNull final String cacheKey, + @NonNull final String overrideExtension) { + if (DEBUG) { + Log.d(TAG, "buildMediaSource() called with: url = [" + sourceUrl + + "], cacheKey = [" + cacheKey + "]" + + "], overrideExtension = [" + overrideExtension + "]"); + } + if (dataSource == null) return null; + + final Uri uri = Uri.parse(sourceUrl); + @C.ContentType final int type = TextUtils.isEmpty(overrideExtension) ? + Util.inferContentType(uri) : Util.inferContentType("." + overrideExtension); + + switch (type) { + case C.TYPE_SS: + return dataSource.getLiveSsMediaSourceFactory().createMediaSource(uri); + case C.TYPE_DASH: + return dataSource.getDashMediaSourceFactory().createMediaSource(uri); + case C.TYPE_HLS: + return dataSource.getHlsMediaSourceFactory().createMediaSource(uri); + case C.TYPE_OTHER: + return dataSource.getExtractorMediaSourceFactory(cacheKey).createMediaSource(uri); + default: throw new IllegalStateException("Unsupported type: " + type); - } } - return mediaSource; } /*////////////////////////////////////////////////////////////////////////// @@ -337,15 +392,16 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen if (intent == null || intent.getAction() == null) return; switch (intent.getAction()) { case AudioManager.ACTION_AUDIO_BECOMING_NOISY: - if (isPlaying()) simpleExoPlayer.setPlayWhenReady(false); + onPause(); break; } } public void unregisterBroadcastReceiver() { - if (broadcastReceiver != null && context != null) { + try { context.unregisterReceiver(broadcastReceiver); - broadcastReceiver = null; + } catch (final IllegalArgumentException unregisteredException) { + Log.e(TAG, "Broadcast receiver already unregistered.", unregisteredException); } } @@ -353,6 +409,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen // States Implementation //////////////////////////////////////////////////////////////////////////*/ + public static final int STATE_PREFLIGHT = -1; public static final int STATE_BLOCKED = 123; public static final int STATE_PLAYING = 124; public static final int STATE_BUFFERING = 125; @@ -360,7 +417,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen public static final int STATE_PAUSED_SEEK = 127; public static final int STATE_COMPLETED = 128; - protected int currentState = -1; + protected int currentState = STATE_PREFLIGHT; public void changeState(int state) { if (DEBUG) Log.d(TAG, "changeState() called with: state = [" + state + "]"); @@ -397,15 +454,13 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen if (!isProgressLoopRunning()) startProgressLoop(); } - public void onBuffering() { - } + public void onBuffering() {} public void onPaused() { if (isProgressLoopRunning()) stopProgressLoop(); } - public void onPausedSeek() { - } + public void onPausedSeek() {} public void onCompleted() { if (DEBUG) Log.d(TAG, "onCompleted() called"); @@ -442,77 +497,102 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen public void onShuffleClicked() { if (DEBUG) Log.d(TAG, "onShuffleClicked() called"); - if (playQueue == null) return; + if (simpleExoPlayer == null) return; + simpleExoPlayer.setShuffleModeEnabled(!simpleExoPlayer.getShuffleModeEnabled()); + } - setRecovery(); - if (playQueue.isShuffled()) { - playQueue.unshuffle(); - } else { - playQueue.shuffle(); - } + /*////////////////////////////////////////////////////////////////////////// + // Progress Updates + //////////////////////////////////////////////////////////////////////////*/ + + public abstract void onUpdateProgress(int currentProgress, int duration, int bufferPercent); + + protected void startProgressLoop() { + if (progressUpdateReactor != null) progressUpdateReactor.dispose(); + progressUpdateReactor = getProgressReactor(); + } + + protected void stopProgressLoop() { + if (progressUpdateReactor != null) progressUpdateReactor.dispose(); + progressUpdateReactor = null; + } + + public void triggerProgressUpdate() { + onUpdateProgress( + (int) simpleExoPlayer.getCurrentPosition(), + (int) simpleExoPlayer.getDuration(), + simpleExoPlayer.getBufferedPercentage() + ); + } + + private Disposable getProgressReactor() { + return Observable.interval(PROGRESS_LOOP_INTERVAL, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignored -> triggerProgressUpdate()); } /*////////////////////////////////////////////////////////////////////////// // ExoPlayer Listener //////////////////////////////////////////////////////////////////////////*/ - private void recover() { - final int currentSourceIndex = playQueue.getIndex(); - final PlayQueueItem currentSourceItem = playQueue.getItem(); - - // Check if already playing correct window - final boolean isCurrentWindowCorrect = - simpleExoPlayer.getCurrentWindowIndex() == currentSourceIndex; - - // Check if recovering - if (isCurrentWindowCorrect && currentSourceItem != null) { - /* Recovering with sub-second position may cause a long buffer delay in ExoPlayer, - * rounding this position to the nearest second will help alleviate this.*/ - final long position = currentSourceItem.getRecoveryPosition(); - - /* Skip recovering if the recovery position is not set.*/ - if (position == PlayQueueItem.RECOVERY_UNSET) return; - - if (DEBUG) Log.d(TAG, "Rewinding to recovery window: " + currentSourceIndex + - " at: " + getTimeString((int)position)); - simpleExoPlayer.seekTo(currentSourceItem.getRecoveryPosition()); - playQueue.unsetRecovery(currentSourceIndex); - } - } - @Override - public void onTimelineChanged(Timeline timeline, Object manifest) { - if (DEBUG) Log.d(TAG, "onTimelineChanged(), timeline size = " + timeline.getWindowCount()); + public void onTimelineChanged(Timeline timeline, Object manifest, + @Player.TimelineChangeReason final int reason) { + if (DEBUG) Log.d(TAG, "ExoPlayer - onTimelineChanged() called with " + + (manifest == null ? "no manifest" : "available manifest") + ", " + + "timeline size = [" + timeline.getWindowCount() + "], " + + "reason = [" + reason + "]"); + if (playQueue == null) return; - if (playbackManager != null) { - playbackManager.load(); + switch (reason) { + case Player.TIMELINE_CHANGE_REASON_RESET: // called after #block + case Player.TIMELINE_CHANGE_REASON_PREPARED: // called after #unblock + case Player.TIMELINE_CHANGE_REASON_DYNAMIC: // called after playlist changes + // Ensures MediaSourceManager#update is complete + final boolean isPlaylistStable = timeline.getWindowCount() == playQueue.size(); + // Ensure dynamic/livestream timeline changes does not cause negative position + if (isPlaylistStable && !isCurrentWindowValid() && !isSynchronizing) { + if (DEBUG) Log.d(TAG, "Playback - negative time position reached, " + + "clamping position to 0ms."); + seekTo(/*clampToTime=*/0); + } + break; } } @Override public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { - if (DEBUG) Log.d(TAG, "onTracksChanged(), track group size = " + trackGroups.length); + if (DEBUG) Log.d(TAG, "ExoPlayer - onTracksChanged(), " + + "track group size = " + trackGroups.length); } @Override public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { - if (DEBUG) Log.d(TAG, "playbackParameters(), speed: " + playbackParameters.speed + ", pitch: " + playbackParameters.pitch); + if (DEBUG) Log.d(TAG, "ExoPlayer - playbackParameters(), " + + "speed: " + playbackParameters.speed + ", " + + "pitch: " + playbackParameters.pitch); } @Override - public void onLoadingChanged(boolean isLoading) { - if (DEBUG) Log.d(TAG, "onLoadingChanged() called with: isLoading = [" + isLoading + "]"); + public void onLoadingChanged(final boolean isLoading) { + if (DEBUG) Log.d(TAG, "ExoPlayer - onLoadingChanged() called with: " + + "isLoading = [" + isLoading + "]"); - if (!isLoading && getCurrentState() == STATE_PAUSED && isProgressLoopRunning()) stopProgressLoop(); - else if (isLoading && !isProgressLoopRunning()) startProgressLoop(); + if (!isLoading && getCurrentState() == STATE_PAUSED && isProgressLoopRunning()) { + stopProgressLoop(); + } else if (isLoading && !isProgressLoopRunning()) { + startProgressLoop(); + } } @Override public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { - if (DEBUG) - Log.d(TAG, "onPlayerStateChanged() called with: playWhenReady = [" + playWhenReady + "], playbackState = [" + playbackState + "]"); + if (DEBUG) Log.d(TAG, "ExoPlayer - onPlayerStateChanged() called with: " + + "playWhenReady = [" + playWhenReady + "], " + + "playbackState = [" + playbackState + "]"); + if (getCurrentState() == STATE_PAUSED_SEEK) { - if (DEBUG) Log.d(TAG, "onPlayerStateChanged() is currently blocked"); + if (DEBUG) Log.d(TAG, "ExoPlayer - onPlayerStateChanged() is currently blocked"); return; } @@ -526,44 +606,61 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen } break; case Player.STATE_READY: //3 - recover(); + maybeCorrectSeekPosition(); if (!isPrepared) { isPrepared = true; onPrepared(playWhenReady); break; } - if (currentState == STATE_PAUSED_SEEK) break; changeState(playWhenReady ? STATE_PLAYING : STATE_PAUSED); break; case Player.STATE_ENDED: // 4 - // Ensure the current window has actually ended - // since single windows that are still loading may produce an ended state - if (isCurrentWindowValid() && simpleExoPlayer.getCurrentPosition() >= simpleExoPlayer.getDuration()) { - changeState(STATE_COMPLETED); - isPrepared = false; - } + changeState(STATE_COMPLETED); + isPrepared = false; break; } } + private void maybeCorrectSeekPosition() { + if (playQueue == null || simpleExoPlayer == null || currentInfo == null) return; + + final int currentSourceIndex = playQueue.getIndex(); + final PlayQueueItem currentSourceItem = playQueue.getItem(); + if (currentSourceItem == null) return; + + final long recoveryPositionMillis = currentSourceItem.getRecoveryPosition(); + final boolean isCurrentWindowCorrect = + simpleExoPlayer.getCurrentPeriodIndex() == currentSourceIndex; + final long presetStartPositionMillis = currentInfo.getStartPosition() * 1000; + + if (recoveryPositionMillis != PlayQueueItem.RECOVERY_UNSET && isCurrentWindowCorrect) { + // Is recovering previous playback? + if (DEBUG) Log.d(TAG, "Playback - Rewinding to recovery time=" + + "[" + getTimeString((int)recoveryPositionMillis) + "]"); + seekTo(recoveryPositionMillis); + playQueue.unsetRecovery(currentSourceIndex); + + } else if (isSynchronizing && simpleExoPlayer.isCurrentWindowDynamic()) { + if (DEBUG) Log.d(TAG, "Playback - Synchronizing livestream to default time"); + // Is still synchronizing? + seekToDefault(); + + } else if (isSynchronizing && presetStartPositionMillis != 0L) { + if (DEBUG) Log.d(TAG, "Playback - Seeking to preset start " + + "position=[" + presetStartPositionMillis + "]"); + // Has another start position? + seekTo(presetStartPositionMillis); + currentInfo.setStartPosition(0); + } + + isSynchronizing = false; + } + /** * Processes the exceptions produced by {@link com.google.android.exoplayer2.ExoPlayer ExoPlayer}. * There are multiple types of errors:

* * {@link ExoPlaybackException#TYPE_SOURCE TYPE_SOURCE}:

- * If the current {@link com.google.android.exoplayer2.Timeline.Window window} is valid, - * then we know the error is produced by transitioning into a bad window, therefore we report - * an error to the play queue based on if the current error can be skipped. - * - * This is done because ExoPlayer reports the source exceptions before window is - * transitioned on seamless playback. Because player error causes ExoPlayer to go - * back to {@link Player#STATE_IDLE STATE_IDLE}, we reset and prepare the media source - * again to resume playback. - * - * In the event that this error is produced during a valid stream playback, we save the - * current position so the playback may be recovered and resumed manually by the user. This - * happens only if the playback is {@link #RECOVERY_SKIP_THRESHOLD} milliseconds until complete. - *

* * {@link ExoPlaybackException#TYPE_UNEXPECTED TYPE_UNEXPECTED}:

* If a runtime error occurred, then we can try to recover it by restarting the playback @@ -572,23 +669,23 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen * {@link ExoPlaybackException#TYPE_RENDERER TYPE_RENDERER}:

* If the renderer failed, treat the error as unrecoverable. * + * @see #processSourceError(IOException) * @see Player.EventListener#onPlayerError(ExoPlaybackException) * */ @Override public void onPlayerError(ExoPlaybackException error) { - if (DEBUG) Log.d(TAG, "onPlayerError() called with: error = [" + error + "]"); + if (DEBUG) Log.d(TAG, "ExoPlayer - onPlayerError() called with: " + + "error = [" + error + "]"); if (errorToast != null) { errorToast.cancel(); errorToast = null; } + savePlaybackState(); + switch (error.type) { case ExoPlaybackException.TYPE_SOURCE: - if (simpleExoPlayer.getCurrentPosition() < - simpleExoPlayer.getDuration() - RECOVERY_SKIP_THRESHOLD) { - setRecovery(); - } - playQueue.error(isCurrentWindowValid()); + processSourceError(error.getSourceException()); showStreamError(error); break; case ExoPlaybackException.TYPE_UNEXPECTED: @@ -598,39 +695,111 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen break; default: showUnrecoverableError(error); - shutdown(); + onPlaybackShutdown(); + break; + } + } + + /** + * Processes {@link ExoPlaybackException} tagged with {@link ExoPlaybackException#TYPE_SOURCE}. + *

+ * If the current {@link com.google.android.exoplayer2.Timeline.Window window} is valid, + * then we know the error is produced by transitioning into a bad window, therefore we report + * an error to the play queue based on if the current error can be skipped. + *

+ * This is done because ExoPlayer reports the source exceptions before window is + * transitioned on seamless playback. Because player error causes ExoPlayer to go + * back to {@link Player#STATE_IDLE STATE_IDLE}, we reset and prepare the media source + * again to resume playback. + *

+ * In the event that this error is produced during a valid stream playback, we save the + * current position so the playback may be recovered and resumed manually by the user. This + * happens only if the playback is {@link #RECOVERY_SKIP_THRESHOLD} milliseconds until complete. + *

+ * In the event of livestreaming being lagged behind for any reason, most notably pausing for + * too long, a {@link BehindLiveWindowException} will be produced. This will trigger a reload + * instead of skipping or removal. + * */ + private void processSourceError(final IOException error) { + if (simpleExoPlayer == null || playQueue == null) return; + + if (simpleExoPlayer.getCurrentPosition() < + simpleExoPlayer.getDuration() - RECOVERY_SKIP_THRESHOLD) { + setRecovery(); + } + + final Throwable cause = error.getCause(); + if (cause instanceof BehindLiveWindowException) { + reload(); + } else if (cause instanceof UnknownHostException) { + playQueue.error(/*isNetworkProblem=*/true); + } else { + playQueue.error(isCurrentWindowValid()); + } + } + + @Override + public void onPositionDiscontinuity(@Player.DiscontinuityReason final int reason) { + if (DEBUG) Log.d(TAG, "ExoPlayer - onPositionDiscontinuity() called with " + + "reason = [" + reason + "]"); + // Refresh the playback if there is a transition to the next video + final int newPeriodIndex = simpleExoPlayer.getCurrentPeriodIndex(); + + /* Discontinuity reasons!! Thank you ExoPlayer lords */ + switch (reason) { + case DISCONTINUITY_REASON_PERIOD_TRANSITION: + if (newPeriodIndex == playQueue.getIndex()) { + registerView(); + } else { + playQueue.offsetIndex(+1); + } + case DISCONTINUITY_REASON_SEEK: + case DISCONTINUITY_REASON_SEEK_ADJUSTMENT: + case DISCONTINUITY_REASON_INTERNAL: break; } } @Override - public void onPositionDiscontinuity() { - // Refresh the playback if there is a transition to the next video - final int newWindowIndex = simpleExoPlayer.getCurrentWindowIndex(); - if (DEBUG) Log.d(TAG, "onPositionDiscontinuity() called with window index = [" + newWindowIndex + "]"); - - // If the user selects a new track, then the discontinuity occurs after the index is changed. - // Therefore, the only source that causes a discrepancy would be gapless transition, - // which can only offset the current track by +1. - if (newWindowIndex == playQueue.getIndex() + 1) { - playQueue.offsetIndex(+1); - } - playbackManager.load(); + public void onRepeatModeChanged(@Player.RepeatMode final int reason) { + if (DEBUG) Log.d(TAG, "ExoPlayer - onRepeatModeChanged() called with: " + + "mode = [" + reason + "]"); } @Override - public void onRepeatModeChanged(int i) { - if (DEBUG) Log.d(TAG, "onRepeatModeChanged() called with: mode = [" + i + "]"); + public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { + if (DEBUG) Log.d(TAG, "ExoPlayer - onShuffleModeEnabledChanged() called with: " + + "mode = [" + shuffleModeEnabled + "]"); + if (playQueue == null) return; + if (shuffleModeEnabled) { + playQueue.shuffle(); + } else { + playQueue.unshuffle(); + } } + @Override + public void onSeekProcessed() { + if (DEBUG) Log.d(TAG, "ExoPlayer - onSeekProcessed() called"); + } /*////////////////////////////////////////////////////////////////////////// // Playback Listener //////////////////////////////////////////////////////////////////////////*/ @Override - public void block() { + public boolean isNearPlaybackEdge(final long timeToEndMillis) { + // If live, then not near playback edge + if (simpleExoPlayer == null || simpleExoPlayer.isCurrentWindowDynamic()) return false; + + final long currentPositionMillis = simpleExoPlayer.getCurrentPosition(); + final long currentDurationMillis = simpleExoPlayer.getDuration(); + return currentDurationMillis - currentPositionMillis < timeToEndMillis; + } + + @Override + public void onPlaybackBlock() { if (simpleExoPlayer == null) return; - if (DEBUG) Log.d(TAG, "Blocking..."); + if (DEBUG) Log.d(TAG, "Playback - onPlaybackBlock() called"); currentItem = null; currentInfo = null; @@ -641,42 +810,102 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen } @Override - public void unblock(final MediaSource mediaSource) { + public void onPlaybackUnblock(final MediaSource mediaSource) { if (simpleExoPlayer == null) return; - if (DEBUG) Log.d(TAG, "Unblocking..."); + if (DEBUG) Log.d(TAG, "Playback - onPlaybackUnblock() called"); if (getCurrentState() == STATE_BLOCKED) changeState(STATE_BUFFERING); simpleExoPlayer.prepare(mediaSource); - simpleExoPlayer.seekToDefaultPosition(); } @Override - public void sync(@NonNull final PlayQueueItem item, - @Nullable final StreamInfo info) { - if (currentItem == item && currentInfo == info) return; - currentItem = item; - currentInfo = info; + public void onPlaybackSynchronize(@NonNull final PlayQueueItem item, + @Nullable final StreamInfo info) { + if (DEBUG) Log.d(TAG, "Playback - onPlaybackSynchronize() called with " + + (info != null ? "available" : "null") + " info, " + + "item=[" + item.getTitle() + "], url=[" + item.getUrl() + "]"); + if (simpleExoPlayer == null || playQueue == null) return; - if (DEBUG) Log.d(TAG, "Syncing..."); - if (simpleExoPlayer == null) return; + final boolean onPlaybackInitial = currentItem == null; + final boolean hasPlayQueueItemChanged = currentItem != item; + final boolean hasStreamInfoChanged = currentInfo != info; - // Check if on wrong window - final int currentSourceIndex = playQueue.indexOf(item); - if (currentSourceIndex != playQueue.getIndex()) { - Log.e(TAG, "Play Queue may be desynchronized: item index=[" + currentSourceIndex + - "], queue index=[" + playQueue.getIndex() + "]"); - } else if (simpleExoPlayer.getCurrentWindowIndex() != currentSourceIndex || !isPlaying()) { - final long startPos = info != null ? info.start_position : 0; - if (DEBUG) Log.d(TAG, "Rewinding to correct window: " + currentSourceIndex + " at: " + getTimeString((int)startPos)); - simpleExoPlayer.seekTo(currentSourceIndex, startPos); + final int currentPlayQueueIndex = playQueue.indexOf(item); + final int currentPlaylistIndex = simpleExoPlayer.getCurrentWindowIndex(); + final int currentPlaylistSize = simpleExoPlayer.getCurrentTimeline().getWindowCount(); + + // when starting playback on the last item when not repeating, maybe auto queue + if (info != null && currentPlayQueueIndex == playQueue.size() - 1 && + getRepeatMode() == Player.REPEAT_MODE_OFF && + PlayerHelper.isAutoQueueEnabled(context)) { + final PlayQueue autoQueue = PlayerHelper.autoQueueOf(info, playQueue.getStreams()); + if (autoQueue != null) playQueue.append(autoQueue.getStreams()); + } + // If nothing to synchronize + if (!hasPlayQueueItemChanged && !hasStreamInfoChanged) { + return; } - initThumbnail(info == null ? item.getThumbnailUrl() : info.thumbnail_url); + currentItem = item; + currentInfo = info; + if (hasPlayQueueItemChanged) { + // updates only to the stream info should not trigger another view count + registerView(); + initThumbnail(info == null ? item.getThumbnailUrl() : info.getThumbnailUrl()); + } + onMetadataChanged(item, info, currentPlayQueueIndex, hasPlayQueueItemChanged); + + // Check if on wrong window + if (currentPlayQueueIndex != playQueue.getIndex()) { + Log.e(TAG, "Playback - Play Queue may be desynchronized: item " + + "index=[" + currentPlayQueueIndex + "], " + + "queue index=[" + playQueue.getIndex() + "]"); + + // Check if bad seek position + } else if ((currentPlaylistSize > 0 && currentPlayQueueIndex >= currentPlaylistSize) || + currentPlayQueueIndex < 0) { + Log.e(TAG, "Playback - Trying to seek to invalid " + + "index=[" + currentPlayQueueIndex + "] with " + + "playlist length=[" + currentPlaylistSize + "]"); + + // If not playing correct stream, change window position and sets flag + // for synchronizing once window position is corrected + // @see maybeCorrectSeekPosition() + } else if (currentPlaylistIndex != currentPlayQueueIndex || onPlaybackInitial || + !isPlaying()) { + if (DEBUG) Log.d(TAG, "Playback - Rewinding to correct" + + " index=[" + currentPlayQueueIndex + "]," + + " from=[" + currentPlaylistIndex + "], size=[" + currentPlaylistSize + "]."); + isSynchronizing = true; + simpleExoPlayer.seekToDefaultPosition(currentPlayQueueIndex); + } + } + + abstract protected void onMetadataChanged(@NonNull final PlayQueueItem item, + @Nullable final StreamInfo info, + final int newPlayQueueIndex, + final boolean hasPlayQueueItemChanged); + + @Nullable + @Override + public MediaSource sourceOf(PlayQueueItem item, StreamInfo info) { + final StreamType streamType = info.getStreamType(); + if (!(streamType == StreamType.AUDIO_LIVE_STREAM || streamType == StreamType.LIVE_STREAM)) { + return null; + } + + if (!info.getHlsUrl().isEmpty()) { + return buildLiveMediaSource(info.getHlsUrl(), C.TYPE_HLS); + } else if (!info.getDashMpdUrl().isEmpty()) { + return buildLiveMediaSource(info.getDashMpdUrl(), C.TYPE_DASH); + } + + return null; } @Override - public void shutdown() { + public void onPlaybackShutdown() { if (DEBUG) Log.d(TAG, "Shutting down..."); destroy(); } @@ -719,26 +948,39 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen changeState(playWhenReady ? STATE_PLAYING : STATE_PAUSED); } - public abstract void onUpdateProgress(int currentProgress, int duration, int bufferPercent); + public void onPlay() { + if (DEBUG) Log.d(TAG, "onPlay() called"); + if (audioReactor == null || playQueue == null || simpleExoPlayer == null) return; - public void onVideoPlayPause() { - if (DEBUG) Log.d(TAG, "onVideoPlayPause() called"); - - if (!isPlaying()) { - audioReactor.requestAudioFocus(); - } else { - audioReactor.abandonAudioFocus(); - } + audioReactor.requestAudioFocus(); if (getCurrentState() == STATE_COMPLETED) { if (playQueue.getIndex() == 0) { - simpleExoPlayer.seekToDefaultPosition(); + seekToDefault(); } else { playQueue.setIndex(0); } } - simpleExoPlayer.setPlayWhenReady(!isPlaying()); + simpleExoPlayer.setPlayWhenReady(true); + } + + public void onPause() { + if (DEBUG) Log.d(TAG, "onPause() called"); + if (audioReactor == null || simpleExoPlayer == null) return; + + audioReactor.abandonAudioFocus(); + simpleExoPlayer.setPlayWhenReady(false); + } + + public void onPlayPause() { + if (DEBUG) Log.d(TAG, "onPlayPause() called"); + + if (!isPlaying()) { + onPlay(); + } else { + onPause(); + } } public void onFastRewind() { @@ -755,12 +997,15 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen if (simpleExoPlayer == null || playQueue == null) return; if (DEBUG) Log.d(TAG, "onPlayPrevious() called"); - /* If current playback has run for PLAY_PREV_ACTIVATION_LIMIT milliseconds, restart current track. - * Also restart the track if the current track is the first in a queue.*/ - if (simpleExoPlayer.getCurrentPosition() > PLAY_PREV_ACTIVATION_LIMIT || playQueue.getIndex() == 0) { - final long startPos = currentInfo == null ? 0 : currentInfo.start_position; - simpleExoPlayer.seekTo(startPos); + /* If current playback has run for PLAY_PREV_ACTIVATION_LIMIT milliseconds, + * restart current track. Also restart the track if the current track + * is the first in a queue.*/ + if (simpleExoPlayer.getCurrentPosition() > PLAY_PREV_ACTIVATION_LIMIT || + playQueue.getIndex() == 0) { + seekToDefault(); + playQueue.offsetIndex(0); } else { + savePlaybackState(); playQueue.offsetIndex(-1); } } @@ -769,27 +1014,34 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen if (playQueue == null) return; if (DEBUG) Log.d(TAG, "onPlayNext() called"); + savePlaybackState(); playQueue.offsetIndex(+1); } public void onSelected(final PlayQueueItem item) { + if (playQueue == null || simpleExoPlayer == null) return; + final int index = playQueue.indexOf(item); if (index == -1) return; - if (playQueue.getIndex() == index) { - simpleExoPlayer.seekToDefaultPosition(); + if (playQueue.getIndex() == index && simpleExoPlayer.getCurrentWindowIndex() == index) { + seekToDefault(); } else { - playQueue.setIndex(index); + savePlaybackState(); } + playQueue.setIndex(index); } - public void seekBy(int milliSeconds) { - if (DEBUG) Log.d(TAG, "seekBy() called with: milliSeconds = [" + milliSeconds + "]"); - if (simpleExoPlayer == null || (isCompleted() && milliSeconds > 0) || ((milliSeconds < 0 && simpleExoPlayer.getCurrentPosition() == 0))) - return; - int progress = (int) (simpleExoPlayer.getCurrentPosition() + milliSeconds); - if (progress < 0) progress = 0; - simpleExoPlayer.seekTo(progress); + public void seekTo(long positionMillis) { + if (DEBUG) Log.d(TAG, "seekBy() called with: position = [" + positionMillis + "]"); + if (simpleExoPlayer == null || positionMillis < 0 || + positionMillis > simpleExoPlayer.getDuration()) return; + simpleExoPlayer.seekTo(positionMillis); + } + + public void seekBy(long offsetMillis) { + if (DEBUG) Log.d(TAG, "seekBy() called with: offsetMillis = [" + offsetMillis + "]"); + seekTo(simpleExoPlayer.getCurrentPosition() + offsetMillis); } public boolean isCurrentWindowValid() { @@ -797,39 +1049,54 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen && simpleExoPlayer.getCurrentPosition() >= 0; } + public void seekToDefault() { + if (simpleExoPlayer != null) simpleExoPlayer.seekToDefaultPosition(); + } + /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ + private void registerView() { + if (databaseUpdateReactor == null || currentInfo == null) return; + databaseUpdateReactor.add(recordManager.onViewed(currentInfo).onErrorComplete() + .subscribe( + ignored -> {/* successful */}, + error -> Log.e(TAG, "Player onViewed() failure: ", error) + )); + } + protected void reload() { if (playbackManager != null) { - playbackManager.reset(); - playbackManager.load(); + playbackManager.dispose(); + } + + if (playQueue != null) { + playbackManager = new MediaSourceManager(this, playQueue); } } - protected void clearThumbnailCache() { - ImageLoader.getInstance().clearMemoryCache(); + protected void savePlaybackState(final StreamInfo info, final long progress) { + if (info == null || databaseUpdateReactor == null) return; + final Disposable stateSaver = recordManager.saveStreamState(info, progress) + .observeOn(AndroidSchedulers.mainThread()) + .onErrorComplete() + .subscribe( + ignored -> {/* successful */}, + error -> Log.e(TAG, "savePlaybackState() failure: ", error) + ); + databaseUpdateReactor.add(stateSaver); } - protected void startProgressLoop() { - if (progressUpdateReactor != null) progressUpdateReactor.dispose(); - progressUpdateReactor = getProgressReactor(); - } + private void savePlaybackState() { + if (simpleExoPlayer == null || currentInfo == null) return; - protected void stopProgressLoop() { - if (progressUpdateReactor != null) progressUpdateReactor.dispose(); - progressUpdateReactor = null; + if (simpleExoPlayer.getCurrentPosition() > RECOVERY_SKIP_THRESHOLD && + simpleExoPlayer.getCurrentPosition() < + simpleExoPlayer.getDuration() - RECOVERY_SKIP_THRESHOLD) { + savePlaybackState(currentInfo, simpleExoPlayer.getCurrentPosition()); + } } - - public void triggerProgressUpdate() { - onUpdateProgress( - (int) simpleExoPlayer.getCurrentPosition(), - (int) simpleExoPlayer.getDuration(), - simpleExoPlayer.getBufferedPercentage() - ); - } - /*////////////////////////////////////////////////////////////////////////// // Getters and Setters //////////////////////////////////////////////////////////////////////////*/ @@ -858,19 +1125,36 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen return currentItem == null ? context.getString(R.string.unknown_content) : currentItem.getUploader(); } - public boolean isCompleted() { - return simpleExoPlayer != null && simpleExoPlayer.getPlaybackState() == Player.STATE_ENDED; + /** Checks if the current playback is a livestream AND is playing at or beyond the live edge */ + public boolean isLiveEdge() { + if (simpleExoPlayer == null) return false; + final boolean isLive = simpleExoPlayer.isCurrentWindowDynamic(); + if (!isLive) return false; + + final Timeline currentTimeline = simpleExoPlayer.getCurrentTimeline(); + final int currentWindowIndex = simpleExoPlayer.getCurrentWindowIndex(); + if (currentTimeline.isEmpty() || currentWindowIndex < 0 || + currentWindowIndex >= currentTimeline.getWindowCount()) { + return false; + } + + Timeline.Window timelineWindow = new Timeline.Window(); + currentTimeline.getWindow(currentWindowIndex, timelineWindow); + return timelineWindow.getDefaultPositionMs() <= simpleExoPlayer.getCurrentPosition(); } public boolean isPlaying() { - return simpleExoPlayer.getPlaybackState() == Player.STATE_READY && simpleExoPlayer.getPlayWhenReady(); + final int state = simpleExoPlayer.getPlaybackState(); + return (state == Player.STATE_READY || state == Player.STATE_BUFFERING) + && simpleExoPlayer.getPlayWhenReady(); } + @Player.RepeatMode public int getRepeatMode() { return simpleExoPlayer.getRepeatMode(); } - public void setRepeatMode(final int repeatMode) { + public void setRepeatMode(@Player.RepeatMode final int repeatMode) { simpleExoPlayer.setRepeatMode(repeatMode); } @@ -909,8 +1193,8 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen return playQueueAdapter; } - public boolean isPlayerReady() { - return currentState == STATE_PLAYING || currentState == STATE_COMPLETED || currentState == STATE_PAUSED; + public boolean isPrepared() { + return isPrepared; } public boolean isProgressLoopRunning() { diff --git a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java index 8081dcad7..dbc34b11a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java @@ -19,7 +19,6 @@ package org.schabi.newpipe.player; -import android.app.Activity; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; @@ -33,9 +32,12 @@ import android.preference.PreferenceManager; import android.provider.Settings; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.RecyclerView; import android.support.v7.widget.helper.ItemTouchHelper; +import android.util.DisplayMetrics; import android.util.Log; +import android.util.TypedValue; import android.view.GestureDetector; import android.view.MotionEvent; import android.view.View; @@ -48,35 +50,48 @@ import android.widget.TextView; import android.widget.Toast; import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; +import com.google.android.exoplayer2.ui.SubtitleView; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; +import org.schabi.newpipe.player.helper.PlaybackParameterDialog; import org.schabi.newpipe.player.helper.PlayerHelper; +import org.schabi.newpipe.playlist.PlayQueue; import org.schabi.newpipe.playlist.PlayQueueItem; import org.schabi.newpipe.playlist.PlayQueueItemBuilder; import org.schabi.newpipe.playlist.PlayQueueItemHolder; +import org.schabi.newpipe.playlist.PlayQueueItemTouchCallback; import org.schabi.newpipe.util.AnimationUtils; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; -import org.schabi.newpipe.util.PopupMenuIconHacker; +import org.schabi.newpipe.util.StateSaver; import org.schabi.newpipe.util.ThemeHelper; import java.util.List; +import java.util.Queue; +import java.util.UUID; +import static org.schabi.newpipe.player.BasePlayer.STATE_PLAYING; +import static org.schabi.newpipe.player.VideoPlayer.DEFAULT_CONTROLS_DURATION; +import static org.schabi.newpipe.player.VideoPlayer.DEFAULT_CONTROLS_HIDE_TIME; +import static org.schabi.newpipe.util.AnimationUtils.Type.SLIDE_AND_ALPHA; +import static org.schabi.newpipe.util.AnimationUtils.animateRotation; import static org.schabi.newpipe.util.AnimationUtils.animateView; +import static org.schabi.newpipe.util.StateSaver.KEY_SAVED_STATE; /** * Activity Player implementing VideoPlayer * * @author mauriciocolli */ -public final class MainVideoPlayer extends Activity { +public final class MainVideoPlayer extends AppCompatActivity + implements StateSaver.WriteRead, PlaybackParameterDialog.Callback { private static final String TAG = ".MainVideoPlayer"; private static final boolean DEBUG = BasePlayer.DEBUG; - private static final String PLAYER_STATE_INTENT = "player_state_intent"; private GestureDetector gestureDetector; @@ -85,6 +100,8 @@ public final class MainVideoPlayer extends Activity { private SharedPreferences defaultPreferences; + @Nullable private StateSaver.SavedState savedState; + /*////////////////////////////////////////////////////////////////////////// // Activity LifeCycle //////////////////////////////////////////////////////////////////////////*/ @@ -98,41 +115,28 @@ public final class MainVideoPlayer extends Activity { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) getWindow().setStatusBarColor(Color.BLACK); setVolumeControlStream(AudioManager.STREAM_MUSIC); - final Intent intent; - if (savedInstanceState != null && savedInstanceState.getParcelable(PLAYER_STATE_INTENT) != null) { - intent = savedInstanceState.getParcelable(PLAYER_STATE_INTENT); - } else { - intent = getIntent(); - } - - if (intent == null) { - Toast.makeText(this, R.string.general_error, Toast.LENGTH_SHORT).show(); - finish(); - return; - } - - showSystemUi(); + hideSystemUi(); setContentView(R.layout.activity_main_player); playerImpl = new VideoPlayerImpl(this); playerImpl.setup(findViewById(android.R.id.content)); - playerImpl.handleIntent(intent); + + if (savedInstanceState != null && savedInstanceState.get(KEY_SAVED_STATE) != null) { + return; // We have saved states, stop here to restore it + } + + final Intent intent = getIntent(); + if (intent != null) { + playerImpl.handleIntent(intent); + } else { + Toast.makeText(this, R.string.general_error, Toast.LENGTH_SHORT).show(); + finish(); + } } @Override - protected void onSaveInstanceState(Bundle outState) { - super.onSaveInstanceState(outState); - if (this.playerImpl == null) return; - - final Intent intent = NavigationHelper.getPlayerIntent( - getApplicationContext(), - this.getClass(), - this.playerImpl.getPlayQueue(), - this.playerImpl.getRepeatMode(), - this.playerImpl.getPlaybackSpeed(), - this.playerImpl.getPlaybackPitch(), - this.playerImpl.getPlaybackQuality() - ); - outState.putParcelable(PLAYER_STATE_INTENT, intent); + protected void onRestoreInstanceState(@NonNull Bundle bundle) { + super.onRestoreInstanceState(bundle); + savedState = StateSaver.tryToRestore(bundle, this); } @Override @@ -142,6 +146,23 @@ public final class MainVideoPlayer extends Activity { playerImpl.handleIntent(intent); } + @Override + protected void onResume() { + super.onResume(); + if (DEBUG) Log.d(TAG, "onResume() called"); + if (playerImpl.getPlayer() != null && activityPaused && playerImpl.wasPlaying() + && !playerImpl.isPlaying()) { + playerImpl.onPlay(); + } + activityPaused = false; + + if(globalScreenOrientationLocked()) { + boolean lastOrientationWasLandscape + = defaultPreferences.getBoolean(getString(R.string.last_orientation_landscape_key), false); + setLandscape(lastOrientationWasLandscape); + } + } + @Override public void onBackPressed() { if (DEBUG) Log.d(TAG, "onBackPressed() called"); @@ -150,36 +171,35 @@ public final class MainVideoPlayer extends Activity { } @Override - protected void onStop() { - super.onStop(); - if (DEBUG) Log.d(TAG, "onStop() called"); - activityPaused = true; + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); - if (playerImpl.getPlayer() != null) { - playerImpl.wasPlaying = playerImpl.getPlayer().getPlayWhenReady(); - playerImpl.setRecovery(); - playerImpl.destroyPlayer(); + if (playerImpl.isSomePopupMenuVisible()) { + playerImpl.getQualityPopupMenu().dismiss(); + playerImpl.getPlaybackSpeedPopupMenu().dismiss(); } } @Override - protected void onResume() { - super.onResume(); - if (DEBUG) Log.d(TAG, "onResume() called"); - if (activityPaused) { - playerImpl.initPlayer(); - playerImpl.getPlayPauseButton().setImageResource(R.drawable.ic_play_arrow_white); + protected void onPause() { + super.onPause(); + if (DEBUG) Log.d(TAG, "onPause() called"); - playerImpl.getPlayer().setPlayWhenReady(playerImpl.wasPlaying); - playerImpl.initPlayback(playerImpl.playQueue); + if (playerImpl != null && playerImpl.getPlayer() != null && !activityPaused) { + playerImpl.wasPlaying = playerImpl.isPlaying(); + playerImpl.onPause(); + } + activityPaused = true; + } - activityPaused = false; - } - if(globalScreenOrientationLocked()) { - boolean lastOrientationWasLandscape - = defaultPreferences.getBoolean(getString(R.string.last_orientation_landscape_key), false); - setLandScape(lastOrientationWasLandscape); - } + @Override + protected void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if (playerImpl == null) return; + + playerImpl.setRecovery(); + savedState = StateSaver.tryToSave(isChangingConfigurations(), savedState, + outState, this); } @Override @@ -189,60 +209,105 @@ public final class MainVideoPlayer extends Activity { if (playerImpl != null) playerImpl.destroy(); } - @Override - public void onConfigurationChanged(Configuration newConfig) { - super.onConfigurationChanged(newConfig); + /*////////////////////////////////////////////////////////////////////////// + // State Saving + //////////////////////////////////////////////////////////////////////////*/ - if (playerImpl.isSomePopupMenuVisible()) { - playerImpl.moreOptionsPopupMenu.dismiss(); - playerImpl.getQualityPopupMenu().dismiss(); - playerImpl.getPlaybackSpeedPopupMenu().dismiss(); - } + @Override + public String generateSuffix() { + return "." + UUID.randomUUID().toString() + ".player"; + } + + @Override + public void writeTo(Queue objectsToSave) { + if (objectsToSave == null) return; + objectsToSave.add(playerImpl.getPlayQueue()); + objectsToSave.add(playerImpl.getRepeatMode()); + objectsToSave.add(playerImpl.getPlaybackSpeed()); + objectsToSave.add(playerImpl.getPlaybackPitch()); + objectsToSave.add(playerImpl.getPlaybackQuality()); + } + + @Override + @SuppressWarnings("unchecked") + public void readFrom(@NonNull Queue savedObjects) throws Exception { + @NonNull final PlayQueue queue = (PlayQueue) savedObjects.poll(); + final int repeatMode = (int) savedObjects.poll(); + final float playbackSpeed = (float) savedObjects.poll(); + final float playbackPitch = (float) savedObjects.poll(); + @NonNull final String playbackQuality = (String) savedObjects.poll(); + + playerImpl.setPlaybackQuality(playbackQuality); + playerImpl.initPlayback(queue, repeatMode, playbackSpeed, playbackPitch); + + StateSaver.onDestroy(savedState); } /*////////////////////////////////////////////////////////////////////////// - // Utils + // View //////////////////////////////////////////////////////////////////////////*/ + /** + * Prior to Kitkat, hiding system ui causes the player view to be overlaid and require two + * clicks to get rid of that invisible overlay. By showing the system UI on actions/events, + * that overlay is removed and the player view is put to the foreground. + * + * Post Kitkat, navbar and status bar can be pulled out by swiping the edge of + * screen, therefore, we can do nothing or hide the UI on actions/events. + * */ + private void changeSystemUi() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + showSystemUi(); + } else { + hideSystemUi(); + } + } + private void showSystemUi() { if (DEBUG) Log.d(TAG, "showSystemUi() called"); if (playerImpl != null && playerImpl.queueVisible) return; + + final int visibility; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { - getWindow().getDecorView().setSystemUiVisibility( - View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - ); - } else getWindow().getDecorView().setSystemUiVisibility(0); + visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION; + } else { + visibility = View.STATUS_BAR_VISIBLE; + } + getWindow().getDecorView().setSystemUiVisibility(visibility); getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); } private void hideSystemUi() { if (DEBUG) Log.d(TAG, "hideSystemUi() called"); - if (android.os.Build.VERSION.SDK_INT >= 16) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_FULLSCREEN - | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN; - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) visibility |= View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; + | View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + visibility |= View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; + } getWindow().getDecorView().setSystemUiVisibility(visibility); } - getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); + getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN); } private void toggleOrientation() { - setLandScape(!isLandScape()); + setLandscape(!isLandscape()); defaultPreferences.edit() - .putBoolean(getString(R.string.last_orientation_landscape_key), !isLandScape()) + .putBoolean(getString(R.string.last_orientation_landscape_key), !isLandscape()) .apply(); } - private boolean isLandScape() { + private boolean isLandscape() { return getResources().getDisplayMetrics().heightPixels < getResources().getDisplayMetrics().widthPixels; } - private void setLandScape(boolean v) { + private void setLandscape(boolean v) { setRequestedOrientation(v ? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE : ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT); @@ -277,6 +342,15 @@ public final class MainVideoPlayer extends Activity { } } + //////////////////////////////////////////////////////////////////////////// + // Playback Parameters Listener + //////////////////////////////////////////////////////////////////////////// + + @Override + public void onPlaybackParameterChanged(float playbackTempo, float playbackPitch) { + if (playerImpl != null) playerImpl.setPlaybackParameters(playbackTempo, playbackPitch); + } + /////////////////////////////////////////////////////////////////////////// @SuppressWarnings({"unused", "WeakerAccess"}) @@ -301,8 +375,12 @@ public final class MainVideoPlayer extends Activity { private boolean queueVisible; private ImageButton moreOptionsButton; - public int moreOptionsPopupMenuGroupId = 89; - public PopupMenu moreOptionsPopupMenu; + private ImageButton toggleOrientationButton; + private ImageButton switchPopupButton; + private ImageButton switchBackgroundButton; + + private RelativeLayout windowRootLayout; + private View secondaryControls; VideoPlayerImpl(final Context context) { super("VideoPlayerImpl" + MainVideoPlayer.TAG, context); @@ -322,9 +400,25 @@ public final class MainVideoPlayer extends Activity { this.playPauseButton = rootView.findViewById(R.id.playPauseButton); this.playPreviousButton = rootView.findViewById(R.id.playPreviousButton); this.playNextButton = rootView.findViewById(R.id.playNextButton); + this.moreOptionsButton = rootView.findViewById(R.id.moreOptionsButton); - this.moreOptionsPopupMenu = new PopupMenu(context, moreOptionsButton); - buildMoreOptionsMenu(); + this.secondaryControls = rootView.findViewById(R.id.secondaryControls); + this.toggleOrientationButton = rootView.findViewById(R.id.toggleOrientation); + this.switchBackgroundButton = rootView.findViewById(R.id.switchBackground); + this.switchPopupButton = rootView.findViewById(R.id.switchPopup); + + this.queueLayout = findViewById(R.id.playQueuePanel); + this.itemsListCloseButton = findViewById(R.id.playQueueClose); + this.itemsList = findViewById(R.id.playQueue); + + this.windowRootLayout = rootView.findViewById(R.id.playbackWindowRoot); + // Prior to Kitkat, there is no way of setting translucent navbar programmatically. + // Thus, fit system windows is opted instead. + // See https://stackoverflow.com/questions/29069070/completely-transparent-status-bar-and-navigation-bar-on-lollipop + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + windowRootLayout.setFitsSystemWindows(false); + windowRootLayout.invalidate(); + } titleTextView.setSelected(true); channelTextView.setSelected(true); @@ -332,6 +426,24 @@ public final class MainVideoPlayer extends Activity { getRootView().setKeepScreenOn(true); } + @Override + protected void setupSubtitleView(@NonNull SubtitleView view, + @NonNull String captionSizeKey) { + final float captionRatioInverse; + if (captionSizeKey.equals(getString(R.string.smaller_caption_size_key))) { + captionRatioInverse = 22f; + } else if (captionSizeKey.equals(getString(R.string.larger_caption_size_key))) { + captionRatioInverse = 18f; + } else { + captionRatioInverse = 20f; + } + + final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + final int minimumLength = Math.min(metrics.heightPixels, metrics.widthPixels); + view.setFixedTextSize(TypedValue.COMPLEX_UNIT_PX, + (float) minimumLength / captionRatioInverse); + } + @Override public void initListeners() { super.initListeners(); @@ -348,7 +460,11 @@ public final class MainVideoPlayer extends Activity { playPauseButton.setOnClickListener(this); playPreviousButton.setOnClickListener(this); playNextButton.setOnClickListener(this); + moreOptionsButton.setOnClickListener(this); + toggleOrientationButton.setOnClickListener(this); + switchBackgroundButton.setOnClickListener(this); + switchPopupButton.setOnClickListener(this); } /*////////////////////////////////////////////////////////////////////////// @@ -361,31 +477,32 @@ public final class MainVideoPlayer extends Activity { updatePlaybackButtons(); } - /*////////////////////////////////////////////////////////////////////////// - // Playback Listener - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void shutdown() { - super.shutdown(); - finish(); - } - - @Override - public void sync(@NonNull final PlayQueueItem item, @Nullable final StreamInfo info) { - super.sync(item, info); - titleTextView.setText(getVideoTitle()); - channelTextView.setText(getUploaderName()); - - //playPauseButton.setImageResource(R.drawable.ic_pause_white); - } - @Override public void onShuffleClicked() { super.onShuffleClicked(); updatePlaybackButtons(); } + /*////////////////////////////////////////////////////////////////////////// + // Playback Listener + //////////////////////////////////////////////////////////////////////////*/ + + protected void onMetadataChanged(@NonNull final PlayQueueItem item, + @Nullable final StreamInfo info, + final int newPlayQueueIndex, + final boolean hasPlayQueueItemChanged) { + super.onMetadataChanged(item, info, newPlayQueueIndex, false); + + titleTextView.setText(getVideoTitle()); + channelTextView.setText(getUploaderName()); + } + + @Override + public void onPlaybackShutdown() { + super.onPlaybackShutdown(); + finish(); + } + /*////////////////////////////////////////////////////////////////////////// // Player Overrides //////////////////////////////////////////////////////////////////////////*/ @@ -445,7 +562,7 @@ public final class MainVideoPlayer extends Activity { public void onClick(View v) { super.onClick(v); if (v.getId() == playPauseButton.getId()) { - onVideoPlayPause(); + onPlayPause(); } else if (v.getId() == playPreviousButton.getId()) { onPlayPrevious(); @@ -464,13 +581,23 @@ public final class MainVideoPlayer extends Activity { return; } else if (v.getId() == moreOptionsButton.getId()) { onMoreOptionsClicked(); + + } else if (v.getId() == toggleOrientationButton.getId()) { + onScreenRotationClicked(); + + } else if (v.getId() == switchPopupButton.getId()) { + onFullScreenButtonClicked(); + + } else if (v.getId() == switchBackgroundButton.getId()) { + onPlayBackgroundButtonClicked(); + } if (getCurrentState() != STATE_COMPLETED) { getControlsVisibilityHandler().removeCallbacksAndMessages(null); - animateView(getControlsRoot(), true, 300, 0, () -> { + animateView(getControlsRoot(), true, DEFAULT_CONTROLS_DURATION, 0, () -> { if (getCurrentState() == STATE_PLAYING && !isSomePopupMenuVisible()) { - hideControls(300, DEFAULT_CONTROLS_HIDE_TIME); + hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); } }); } @@ -484,22 +611,28 @@ public final class MainVideoPlayer extends Activity { updatePlaybackButtons(); getControlsRoot().setVisibility(View.INVISIBLE); - queueLayout.setVisibility(View.VISIBLE); + animateView(queueLayout, SLIDE_AND_ALPHA, /*visible=*/true, + DEFAULT_CONTROLS_DURATION); itemsList.scrollToPosition(playQueue.getIndex()); } private void onQueueClosed() { - queueLayout.setVisibility(View.GONE); + animateView(queueLayout, SLIDE_AND_ALPHA, /*visible=*/false, + DEFAULT_CONTROLS_DURATION); queueVisible = false; } private void onMoreOptionsClicked() { if (DEBUG) Log.d(TAG, "onMoreOptionsClicked() called"); - moreOptionsPopupMenu.show(); - isSomePopupMenuVisible = true; - showControls(300); + final boolean isMoreControlsVisible = secondaryControls.getVisibility() == View.VISIBLE; + + animateRotation(moreOptionsButton, DEFAULT_CONTROLS_DURATION, + isMoreControlsVisible ? 0 : 180); + animateView(secondaryControls, SLIDE_AND_ALPHA, !isMoreControlsVisible, + DEFAULT_CONTROLS_DURATION); + showControls(DEFAULT_CONTROLS_DURATION); } private void onScreenRotationClicked() { @@ -508,18 +641,35 @@ public final class MainVideoPlayer extends Activity { showControlsThenHide(); } + @Override + public void onPlaybackSpeedClicked() { + PlaybackParameterDialog.newInstance(getPlaybackSpeed(), getPlaybackPitch()) + .show(getSupportFragmentManager(), TAG); + } + @Override public void onStopTrackingTouch(SeekBar seekBar) { super.onStopTrackingTouch(seekBar); - if (wasPlaying()) { - hideControls(100, 0); - } + if (wasPlaying()) showControlsThenHide(); } @Override public void onDismiss(PopupMenu menu) { super.onDismiss(menu); - if (isPlaying()) hideControls(300, 0); + if (isPlaying()) hideControls(DEFAULT_CONTROLS_DURATION, 0); + hideSystemUi(); + } + + @Override + protected int nextResizeMode(int currentResizeMode) { + switch (currentResizeMode) { + case AspectRatioFrameLayout.RESIZE_MODE_FIT: + return AspectRatioFrameLayout.RESIZE_MODE_FILL; + case AspectRatioFrameLayout.RESIZE_MODE_FILL: + return AspectRatioFrameLayout.RESIZE_MODE_ZOOM; + default: + return AspectRatioFrameLayout.RESIZE_MODE_FIT; + } } @Override @@ -565,7 +715,7 @@ public final class MainVideoPlayer extends Activity { playPauseButton.setImageResource(R.drawable.ic_pause_white); animatePlayButtons(true, 200); }); - showSystemUi(); + getRootView().setKeepScreenOn(true); } @@ -577,7 +727,7 @@ public final class MainVideoPlayer extends Activity { animatePlayButtons(true, 200); }); - showSystemUi(); + changeSystemUi(); getRootView().setKeepScreenOn(false); } @@ -591,10 +741,9 @@ public final class MainVideoPlayer extends Activity { @Override public void onCompleted() { - showSystemUi(); animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, false, 0, 0, () -> { playPauseButton.setImageResource(R.drawable.ic_replay_white); - animatePlayButtons(true, 300); + animatePlayButtons(true, DEFAULT_CONTROLS_DURATION); }); getRootView().setKeepScreenOn(false); @@ -624,8 +773,9 @@ public final class MainVideoPlayer extends Activity { if (DEBUG) Log.d(TAG, "hideControls() called with: delay = [" + delay + "]"); getControlsVisibilityHandler().removeCallbacksAndMessages(null); getControlsVisibilityHandler().postDelayed(() -> - animateView(getControlsRoot(), false, duration, 0, MainVideoPlayer.this::hideSystemUi), - delay + animateView(getControlsRoot(), false, duration, 0, + MainVideoPlayer.this::hideSystemUi), + /*delayMillis=*/delay ); } @@ -637,48 +787,7 @@ public final class MainVideoPlayer extends Activity { setShuffleButton(shuffleButton, playQueue.isShuffled()); } - private void buildMoreOptionsMenu() { - this.moreOptionsPopupMenu.getMenuInflater().inflate(R.menu.menu_videooptions, - moreOptionsPopupMenu.getMenu()); - - moreOptionsPopupMenu.setOnMenuItemClickListener(menuItem -> { - switch (menuItem.getItemId()) { - case R.id.toggleOrientation: - onScreenRotationClicked(); - break; - case R.id.switchPopup: - onFullScreenButtonClicked(); - break; - case R.id.switchBackground: - onPlayBackgroundButtonClicked(); - break; - } - return false; - }); - - try { - PopupMenuIconHacker.setShowPopupIcon(moreOptionsPopupMenu); - } catch (Exception e) { - e.printStackTrace(); - } - - // fix icon theme - if(ThemeHelper.isLightThemeSelected(MainVideoPlayer.this)) { - moreOptionsPopupMenu.getMenu() - .findItem(R.id.toggleOrientation) - .setIcon(R.drawable.ic_screen_rotation_black_24dp); - moreOptionsPopupMenu.getMenu() - .findItem(R.id.switchPopup) - .setIcon(R.drawable.ic_fullscreen_exit_black_24dp); - } - } - private void buildQueue() { - queueLayout = findViewById(R.id.playQueuePanel); - - itemsListCloseButton = findViewById(R.id.playQueueClose); - - itemsList = findViewById(R.id.playQueue); itemsList.setAdapter(playQueueAdapter); itemsList.setClickable(true); itemsList.setLongClickable(true); @@ -708,31 +817,11 @@ public final class MainVideoPlayer extends Activity { } private ItemTouchHelper.SimpleCallback getItemTouchCallback() { - return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) { + return new PlayQueueItemTouchCallback() { @Override - public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder source, RecyclerView.ViewHolder target) { - if (source.getItemViewType() != target.getItemViewType()) { - return false; - } - - final int sourceIndex = source.getLayoutPosition(); - final int targetIndex = target.getLayoutPosition(); - playQueue.move(sourceIndex, targetIndex); - return true; + public void onMove(int sourceIndex, int targetIndex) { + if (playQueue != null) playQueue.move(sourceIndex, targetIndex); } - - @Override - public boolean isLongPressDragEnabled() { - return false; - } - - @Override - public boolean isItemViewSwipeEnabled() { - return false; - } - - @Override - public void onSwiped(RecyclerView.ViewHolder viewHolder, int swipeDir) {} }; } @@ -807,14 +896,22 @@ public final class MainVideoPlayer extends Activity { if (DEBUG) Log.d(TAG, "onSingleTapConfirmed() called with: e = [" + e + "]"); if (playerImpl.getCurrentState() == BasePlayer.STATE_BLOCKED) return true; - if (playerImpl.isControlsVisible()) playerImpl.hideControls(150, 0); - else { + if (playerImpl.isControlsVisible()) { + playerImpl.hideControls(150, 0); + } else { playerImpl.showControlsThenHide(); - showSystemUi(); + changeSystemUi(); } return true; } + @Override + public boolean onDown(MotionEvent e) { + if (DEBUG) Log.d(TAG, "onDown() called with: e = [" + e + "]"); + + return super.onDown(e); + } + private final boolean isPlayerGestureEnabled = PlayerHelper.isPlayerGestureEnabled(getApplicationContext()); private final float stepsBrightness = 15, stepBrightness = (1f / stepsBrightness), minBrightness = .01f; @@ -893,11 +990,15 @@ public final class MainVideoPlayer extends Activity { eventsNum = 0; /* if (playerImpl.getVolumeTextView().getVisibility() == View.VISIBLE) playerImpl.getVolumeTextView().setVisibility(View.GONE); if (playerImpl.getBrightnessTextView().getVisibility() == View.VISIBLE) playerImpl.getBrightnessTextView().setVisibility(View.GONE);*/ - if (playerImpl.getVolumeTextView().getVisibility() == View.VISIBLE) animateView(playerImpl.getVolumeTextView(), false, 200, 200); - if (playerImpl.getBrightnessTextView().getVisibility() == View.VISIBLE) animateView(playerImpl.getBrightnessTextView(), false, 200, 200); + if (playerImpl.getVolumeTextView().getVisibility() == View.VISIBLE) { + animateView(playerImpl.getVolumeTextView(), false, 200, 200); + } + if (playerImpl.getBrightnessTextView().getVisibility() == View.VISIBLE) { + animateView(playerImpl.getBrightnessTextView(), false, 200, 200); + } - if (playerImpl.isControlsVisible() && playerImpl.getCurrentState() == BasePlayer.STATE_PLAYING) { - playerImpl.hideControls(300, VideoPlayer.DEFAULT_CONTROLS_HIDE_TIME); + if (playerImpl.isControlsVisible() && playerImpl.getCurrentState() == STATE_PLAYING) { + playerImpl.hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java index c3803f0d5..20860d9c5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java @@ -49,8 +49,11 @@ import android.widget.RemoteViews; import android.widget.SeekBar; import android.widget.TextView; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; +import com.google.android.exoplayer2.ui.SubtitleView; import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.R; @@ -67,6 +70,9 @@ import org.schabi.newpipe.util.ThemeHelper; import java.util.List; +import static org.schabi.newpipe.player.BasePlayer.STATE_PLAYING; +import static org.schabi.newpipe.player.VideoPlayer.DEFAULT_CONTROLS_DURATION; +import static org.schabi.newpipe.player.VideoPlayer.DEFAULT_CONTROLS_HIDE_TIME; import static org.schabi.newpipe.player.helper.PlayerHelper.isUsingOldPlayer; import static org.schabi.newpipe.util.AnimationUtils.animateView; @@ -88,6 +94,8 @@ public final class PopupVideoPlayer extends Service { private static final String POPUP_SAVED_X = "popup_saved_x"; private static final String POPUP_SAVED_Y = "popup_saved_y"; + private static final int MINIMUM_SHOW_EXTRA_WIDTH_DP = 300; + private WindowManager windowManager; private WindowManager.LayoutParams windowLayoutParams; private GestureDetector gestureDetector; @@ -358,10 +366,12 @@ public final class PopupVideoPlayer extends Service { /////////////////////////////////////////////////////////////////////////// - protected class VideoPlayerImpl extends VideoPlayer { + protected class VideoPlayerImpl extends VideoPlayer implements View.OnLayoutChangeListener { private TextView resizingIndicator; private ImageButton fullScreenButton; + private View extraOptionsView; + @Override public void handleIntent(Intent intent) { super.handleIntent(intent); @@ -380,6 +390,29 @@ public final class PopupVideoPlayer extends Service { resizingIndicator = rootView.findViewById(R.id.resizing_indicator); fullScreenButton = rootView.findViewById(R.id.fullScreenButton); fullScreenButton.setOnClickListener(v -> onFullScreenButtonClicked()); + + extraOptionsView = rootView.findViewById(R.id.extraOptionsView); + rootView.addOnLayoutChangeListener(this); + } + + @Override + protected void setupSubtitleView(@NonNull SubtitleView view, + @NonNull String captionSizeKey) { + float captionRatio = SubtitleView.DEFAULT_TEXT_SIZE_FRACTION; + if (captionSizeKey.equals(getString(R.string.smaller_caption_size_key))) { + captionRatio *= 0.9; + } else if (captionSizeKey.equals(getString(R.string.larger_caption_size_key))) { + captionRatio *= 1.1; + } + view.setFractionalTextSize(captionRatio); + } + + @Override + public void onLayoutChange(final View view, int left, int top, int right, int bottom, + int oldLeft, int oldTop, int oldRight, int oldBottom) { + float widthDp = Math.abs(right - left) / getResources().getDisplayMetrics().density; + final int visibility = widthDp > MINIMUM_SHOW_EXTRA_WIDTH_DP ? View.VISIBLE : View.GONE; + extraOptionsView.setVisibility(visibility); } @Override @@ -389,13 +422,15 @@ public final class PopupVideoPlayer extends Service { } @Override - public void onThumbnailReceived(Bitmap thumbnail) { - super.onThumbnailReceived(thumbnail); - if (thumbnail != null) { + public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) { + super.onLoadingComplete(imageUri, view, loadedImage); + if (loadedImage != null) { // rebuild notification here since remote view does not release bitmaps, causing memory leaks notBuilder = createNotification(); - if (notRemoteView != null) notRemoteView.setImageViewBitmap(R.id.notificationCover, thumbnail); + if (notRemoteView != null) { + notRemoteView.setImageViewBitmap(R.id.notificationCover, loadedImage); + } updateNotification(-1); } @@ -438,6 +473,15 @@ public final class PopupVideoPlayer extends Service { if (isPlaying()) hideControls(500, 0); } + @Override + protected int nextResizeMode(int resizeMode) { + if (resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FILL) { + return AspectRatioFrameLayout.RESIZE_MODE_FIT; + } else { + return AspectRatioFrameLayout.RESIZE_MODE_FILL; + } + } + @Override public void onStopTrackingTouch(SeekBar seekBar) { super.onStopTrackingTouch(seekBar); @@ -494,7 +538,8 @@ public final class PopupVideoPlayer extends Service { private void updatePlayback() { if (activityListener != null && simpleExoPlayer != null && playQueue != null) { - activityListener.onPlaybackUpdate(currentState, getRepeatMode(), playQueue.isShuffled(), simpleExoPlayer.getPlaybackParameters()); + activityListener.onPlaybackUpdate(currentState, getRepeatMode(), + playQueue.isShuffled(), simpleExoPlayer.getPlaybackParameters()); } } @@ -533,16 +578,17 @@ public final class PopupVideoPlayer extends Service { // Playback Listener //////////////////////////////////////////////////////////////////////////*/ - @Override - public void sync(@NonNull PlayQueueItem item, @Nullable StreamInfo info) { - if (currentItem == item && currentInfo == info) return; - super.sync(item, info); + protected void onMetadataChanged(@NonNull final PlayQueueItem item, + @Nullable final StreamInfo info, + final int newPlayQueueIndex, + final boolean hasPlayQueueItemChanged) { + super.onMetadataChanged(item, info, newPlayQueueIndex, false); updateMetadata(); } @Override - public void shutdown() { - super.shutdown(); + public void onPlaybackShutdown() { + super.onPlaybackShutdown(); onClose(); } @@ -572,7 +618,7 @@ public final class PopupVideoPlayer extends Service { onClose(); break; case ACTION_PLAY_PAUSE: - onVideoPlayPause(); + onPlayPause(); break; case ACTION_REPEAT: onRepeatClicked(); @@ -607,6 +653,8 @@ public final class PopupVideoPlayer extends Service { super.onPlaying(); updateNotification(R.drawable.ic_pause_white); lockManager.acquireWifiAndCpu(); + + hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); } @Override @@ -642,8 +690,8 @@ public final class PopupVideoPlayer extends Service { //////////////////////////////////////////////////////////////////////////*/ /*package-private*/ void enableVideoRenderer(final boolean enable) { - final int videoRendererIndex = getVideoRendererIndex(); - if (trackSelector != null && videoRendererIndex != -1) { + final int videoRendererIndex = getRendererIndex(C.TRACK_TYPE_VIDEO); + if (trackSelector != null && videoRendererIndex != RENDERER_UNAVAILABLE) { trackSelector.setRendererDisabled(videoRendererIndex, !enable); } } @@ -668,7 +716,7 @@ public final class PopupVideoPlayer extends Service { public boolean onDoubleTap(MotionEvent e) { if (DEBUG) Log.d(TAG, "onDoubleTap() called with: e = [" + e + "]" + "rawXy = " + e.getRawX() + ", " + e.getRawY() + ", xy = " + e.getX() + ", " + e.getY()); - if (playerImpl == null || !playerImpl.isPlaying() || !playerImpl.isPlayerReady()) return false; + if (playerImpl == null || !playerImpl.isPlaying()) return false; if (e.getX() > popupWidth / 2) { playerImpl.onFastForward(); @@ -683,7 +731,7 @@ public final class PopupVideoPlayer extends Service { public boolean onSingleTapConfirmed(MotionEvent e) { if (DEBUG) Log.d(TAG, "onSingleTapConfirmed() called with: e = [" + e + "]"); if (playerImpl == null || playerImpl.getPlayer() == null) return false; - playerImpl.onVideoPlayPause(); + playerImpl.onPlayPause(); return true; } @@ -739,8 +787,8 @@ public final class PopupVideoPlayer extends Service { private void onScrollEnd() { if (DEBUG) Log.d(TAG, "onScrollEnd() called"); if (playerImpl == null) return; - if (playerImpl.isControlsVisible() && playerImpl.getCurrentState() == BasePlayer.STATE_PLAYING) { - playerImpl.hideControls(300, VideoPlayer.DEFAULT_CONTROLS_HIDE_TIME); + if (playerImpl.isControlsVisible() && playerImpl.getCurrentState() == STATE_PLAYING) { + playerImpl.hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java b/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java index 4165dc087..239c9c8d3 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java @@ -29,10 +29,13 @@ import com.google.android.exoplayer2.Player; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; +import org.schabi.newpipe.fragments.local.dialog.PlaylistAppendDialog; import org.schabi.newpipe.player.event.PlayerEventListener; +import org.schabi.newpipe.player.helper.PlaybackParameterDialog; import org.schabi.newpipe.playlist.PlayQueueItem; import org.schabi.newpipe.playlist.PlayQueueItemBuilder; import org.schabi.newpipe.playlist.PlayQueueItemHolder; +import org.schabi.newpipe.playlist.PlayQueueItemTouchCallback; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.ThemeHelper; @@ -41,7 +44,8 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.formatPitch; import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; public abstract class ServicePlayerActivity extends AppCompatActivity - implements PlayerEventListener, SeekBar.OnSeekBarChangeListener, View.OnClickListener { + implements PlayerEventListener, SeekBar.OnSeekBarChangeListener, + View.OnClickListener, PlaybackParameterDialog.Callback { private boolean serviceBound; private ServiceConnection serviceConnection; @@ -55,8 +59,6 @@ public abstract class ServicePlayerActivity extends AppCompatActivity //////////////////////////////////////////////////////////////////////////// private static final int RECYCLER_ITEM_POPUP_MENU_GROUP_ID = 47; - private static final int PLAYBACK_SPEED_POPUP_MENU_GROUP_ID = 61; - private static final int PLAYBACK_PITCH_POPUP_MENU_GROUP_ID = 97; private static final int SMOOTH_SCROLL_MAXIMUM_DISTANCE = 80; @@ -72,6 +74,7 @@ public abstract class ServicePlayerActivity extends AppCompatActivity private SeekBar progressSeekBar; private TextView progressCurrentTime; private TextView progressEndTime; + private TextView progressLiveSync; private TextView seekDisplay; private ImageButton repeatButton; @@ -82,9 +85,7 @@ public abstract class ServicePlayerActivity extends AppCompatActivity private ProgressBar progressBar; private TextView playbackSpeedButton; - private PopupMenu playbackSpeedPopupMenu; private TextView playbackPitchButton; - private PopupMenu playbackPitchPopupMenu; //////////////////////////////////////////////////////////////////////////// // Abstracts @@ -149,8 +150,8 @@ public abstract class ServicePlayerActivity extends AppCompatActivity case android.R.id.home: finish(); return true; - case R.id.action_history: - NavigationHelper.openHistory(this); + case R.id.action_append_playlist: + appendToPlaylist(); return true; case R.id.action_settings: NavigationHelper.openSettings(this); @@ -185,6 +186,14 @@ public abstract class ServicePlayerActivity extends AppCompatActivity null ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); } + + private void appendToPlaylist() { + if (this.player != null && this.player.getPlayQueue() != null) { + PlaylistAppendDialog.fromPlayQueueItems(this.player.getPlayQueue().getStreams()) + .show(getSupportFragmentManager(), getTag()); + } + } + //////////////////////////////////////////////////////////////////////////// // Service Connection //////////////////////////////////////////////////////////////////////////// @@ -202,6 +211,15 @@ public abstract class ServicePlayerActivity extends AppCompatActivity unbindService(serviceConnection); serviceBound = false; stopPlayerListener(); + + if (player != null && player.getPlayQueueAdapter() != null) { + player.getPlayQueueAdapter().unsetSelectedListener(); + } + if (itemsList != null) itemsList.setAdapter(null); + if (itemTouchHelper != null) itemTouchHelper.attachToRecyclerView(null); + + itemsList = null; + itemTouchHelper = null; player = null; } } @@ -273,9 +291,11 @@ public abstract class ServicePlayerActivity extends AppCompatActivity progressCurrentTime = rootView.findViewById(R.id.current_time); progressSeekBar = rootView.findViewById(R.id.seek_bar); progressEndTime = rootView.findViewById(R.id.end_time); + progressLiveSync = rootView.findViewById(R.id.live_sync); seekDisplay = rootView.findViewById(R.id.seek_display); progressSeekBar.setOnSeekBarChangeListener(this); + progressLiveSync.setOnClickListener(this); } private void buildControls() { @@ -295,45 +315,6 @@ public abstract class ServicePlayerActivity extends AppCompatActivity shuffleButton.setOnClickListener(this); playbackSpeedButton.setOnClickListener(this); playbackPitchButton.setOnClickListener(this); - - playbackSpeedPopupMenu = new PopupMenu(this, playbackSpeedButton); - playbackPitchPopupMenu = new PopupMenu(this, playbackPitchButton); - buildPlaybackSpeedMenu(); - buildPlaybackPitchMenu(); - } - - private void buildPlaybackSpeedMenu() { - if (playbackSpeedPopupMenu == null) return; - - playbackSpeedPopupMenu.getMenu().removeGroup(PLAYBACK_SPEED_POPUP_MENU_GROUP_ID); - for (int i = 0; i < BasePlayer.PLAYBACK_SPEEDS.length; i++) { - final float playbackSpeed = BasePlayer.PLAYBACK_SPEEDS[i]; - final String formattedSpeed = formatSpeed(playbackSpeed); - final MenuItem item = playbackSpeedPopupMenu.getMenu().add(PLAYBACK_SPEED_POPUP_MENU_GROUP_ID, i, Menu.NONE, formattedSpeed); - item.setOnMenuItemClickListener(menuItem -> { - if (player == null) return false; - - player.setPlaybackSpeed(playbackSpeed); - return true; - }); - } - } - - private void buildPlaybackPitchMenu() { - if (playbackPitchPopupMenu == null) return; - - playbackPitchPopupMenu.getMenu().removeGroup(PLAYBACK_PITCH_POPUP_MENU_GROUP_ID); - for (int i = 0; i < BasePlayer.PLAYBACK_PITCHES.length; i++) { - final float playbackPitch = BasePlayer.PLAYBACK_PITCHES[i]; - final String formattedPitch = formatPitch(playbackPitch); - final MenuItem item = playbackPitchPopupMenu.getMenu().add(PLAYBACK_PITCH_POPUP_MENU_GROUP_ID, i, Menu.NONE, formattedPitch); - item.setOnMenuItemClickListener(menuItem -> { - if (player == null) return false; - - player.setPlaybackPitch(playbackPitch); - return true; - }); - } } private void buildItemPopupMenu(final PlayQueueItem item, final View view) { @@ -374,31 +355,11 @@ public abstract class ServicePlayerActivity extends AppCompatActivity } private ItemTouchHelper.SimpleCallback getItemTouchCallback() { - return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) { + return new PlayQueueItemTouchCallback() { @Override - public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder source, RecyclerView.ViewHolder target) { - if (source.getItemViewType() != target.getItemViewType()) { - return false; - } - - final int sourceIndex = source.getLayoutPosition(); - final int targetIndex = target.getLayoutPosition(); + public void onMove(int sourceIndex, int targetIndex) { if (player != null) player.getPlayQueue().move(sourceIndex, targetIndex); - return true; } - - @Override - public boolean isLongPressDragEnabled() { - return false; - } - - @Override - public boolean isItemViewSwipeEnabled() { - return false; - } - - @Override - public void onSwiped(RecyclerView.ViewHolder viewHolder, int swipeDir) {} }; } @@ -463,7 +424,7 @@ public abstract class ServicePlayerActivity extends AppCompatActivity player.onPlayPrevious(); } else if (view.getId() == playPauseButton.getId()) { - player.onVideoPlayPause(); + player.onPlayPause(); } else if (view.getId() == forwardButton.getId()) { player.onPlayNext(); @@ -472,17 +433,35 @@ public abstract class ServicePlayerActivity extends AppCompatActivity player.onShuffleClicked(); } else if (view.getId() == playbackSpeedButton.getId()) { - playbackSpeedPopupMenu.show(); + openPlaybackParameterDialog(); } else if (view.getId() == playbackPitchButton.getId()) { - playbackPitchPopupMenu.show(); + openPlaybackParameterDialog(); } else if (view.getId() == metadata.getId()) { scrollToSelected(); + } else if (view.getId() == progressLiveSync.getId()) { + player.seekToDefault(); + } } + //////////////////////////////////////////////////////////////////////////// + // Playback Parameters + //////////////////////////////////////////////////////////////////////////// + + private void openPlaybackParameterDialog() { + if (player == null) return; + PlaybackParameterDialog.newInstance(player.getPlaybackSpeed(), + player.getPlaybackPitch()).show(getSupportFragmentManager(), getTag()); + } + + @Override + public void onPlaybackParameterChanged(float playbackTempo, float playbackPitch) { + if (player != null) player.setPlaybackParameters(playbackTempo, playbackPitch); + } + //////////////////////////////////////////////////////////////////////////// // Seekbar Listener //////////////////////////////////////////////////////////////////////////// @@ -504,7 +483,7 @@ public abstract class ServicePlayerActivity extends AppCompatActivity @Override public void onStopTrackingTouch(SeekBar seekBar) { - if (player != null) player.simpleExoPlayer.seekTo(seekBar.getProgress()); + if (player != null) player.seekTo(seekBar.getProgress()); seekDisplay.setVisibility(View.GONE); seeking = false; } @@ -534,13 +513,30 @@ public abstract class ServicePlayerActivity extends AppCompatActivity progressSeekBar.setProgress(currentProgress); progressCurrentTime.setText(Localization.getDurationString(currentProgress / 1000)); } + + if (player != null) { + progressLiveSync.setClickable(!player.isLiveEdge()); + } } @Override public void onMetadataUpdate(StreamInfo info) { if (info != null) { metadataTitle.setText(info.getName()); - metadataArtist.setText(info.uploader_name); + metadataArtist.setText(info.getUploaderName()); + + progressEndTime.setVisibility(View.GONE); + progressLiveSync.setVisibility(View.GONE); + switch (info.getStreamType()) { + case LIVE_STREAM: + case AUDIO_LIVE_STREAM: + progressLiveSync.setVisibility(View.VISIBLE); + break; + default: + progressEndTime.setVisibility(View.VISIBLE); + break; + } + scrollToSelected(); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java index 5399ff047..b019ea91e 100644 --- a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java @@ -29,8 +29,10 @@ import android.content.Intent; import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.PorterDuff; +import android.net.Uri; import android.os.Build; import android.os.Handler; +import android.preference.PreferenceManager; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.content.ContextCompat; @@ -46,17 +48,26 @@ import android.widget.SeekBar; import android.widget.TextView; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MergingMediaSource; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; +import com.google.android.exoplayer2.ui.SubtitleView; +import com.google.android.exoplayer2.video.VideoListener; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.Subtitles; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.playlist.PlayQueueItem; import org.schabi.newpipe.util.AnimationUtils; import org.schabi.newpipe.util.ListHelper; @@ -64,6 +75,8 @@ import org.schabi.newpipe.util.ListHelper; import java.util.ArrayList; import java.util.List; +import static com.google.android.exoplayer2.C.SELECTION_FLAG_AUTOSELECT; +import static com.google.android.exoplayer2.C.TIME_UNSET; import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; import static org.schabi.newpipe.util.AnimationUtils.animateView; @@ -75,7 +88,7 @@ import static org.schabi.newpipe.util.AnimationUtils.animateView; */ @SuppressWarnings({"WeakerAccess", "unused"}) public abstract class VideoPlayer extends BasePlayer - implements SimpleExoPlayer.VideoListener, + implements VideoListener, SeekBar.OnSeekBarChangeListener, View.OnClickListener, Player.EventListener, @@ -88,6 +101,8 @@ public abstract class VideoPlayer extends BasePlayer // Player //////////////////////////////////////////////////////////////////////////*/ + protected static final int RENDERER_UNAVAILABLE = -1; + public static final int DEFAULT_CONTROLS_DURATION = 300; // 300 millis public static final int DEFAULT_CONTROLS_HIDE_TIME = 2000; // 2 Seconds private ArrayList availableStreams; @@ -118,11 +133,17 @@ public abstract class VideoPlayer extends BasePlayer private SeekBar playbackSeekBar; private TextView playbackCurrentTime; private TextView playbackEndTime; + private TextView playbackLiveSync; private TextView playbackSpeedTextView; private View topControlsRoot; private TextView qualityTextView; + private SubtitleView subtitleView; + + private TextView resizeView; + private TextView captionTextView; + private ValueAnimator controlViewAnimator; private Handler controlsVisibilityHandler = new Handler(); @@ -133,12 +154,14 @@ public abstract class VideoPlayer extends BasePlayer private int playbackSpeedPopupMenuGroupId = 79; private PopupMenu playbackSpeedPopupMenu; + private int captionPopupMenuGroupId = 89; + private PopupMenu captionPopupMenu; + /////////////////////////////////////////////////////////////////////////// public VideoPlayer(String debugTag, Context context) { super(context); this.TAG = debugTag; - this.context = context; } public void setup(View rootView) { @@ -159,11 +182,23 @@ public abstract class VideoPlayer extends BasePlayer this.playbackSeekBar = rootView.findViewById(R.id.playbackSeekBar); this.playbackCurrentTime = rootView.findViewById(R.id.playbackCurrentTime); this.playbackEndTime = rootView.findViewById(R.id.playbackEndTime); + this.playbackLiveSync = rootView.findViewById(R.id.playbackLiveSync); this.playbackSpeedTextView = rootView.findViewById(R.id.playbackSpeed); this.bottomControlsRoot = rootView.findViewById(R.id.bottomControls); this.topControlsRoot = rootView.findViewById(R.id.topControls); this.qualityTextView = rootView.findViewById(R.id.qualityTextView); + this.subtitleView = rootView.findViewById(R.id.subtitleView); + final String captionSizeKey = PreferenceManager.getDefaultSharedPreferences(context) + .getString(context.getString(R.string.caption_size_key), + context.getString(R.string.caption_size_default)); + setupSubtitleView(subtitleView, captionSizeKey); + + this.resizeView = rootView.findViewById(R.id.resizeTextView); + resizeView.setText(PlayerHelper.resizeTypeOf(context, aspectRatioFrameLayout.getResizeMode())); + + this.captionTextView = rootView.findViewById(R.id.captionTextView); + //this.aspectRatioFrameLayout.setAspectRatio(16.0f / 9.0f); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) @@ -172,25 +207,38 @@ public abstract class VideoPlayer extends BasePlayer this.qualityPopupMenu = new PopupMenu(context, qualityTextView); this.playbackSpeedPopupMenu = new PopupMenu(context, playbackSpeedTextView); + this.captionPopupMenu = new PopupMenu(context, captionTextView); - ((ProgressBar) this.loadingPanel.findViewById(R.id.progressBarLoadingPanel)).getIndeterminateDrawable().setColorFilter(Color.WHITE, PorterDuff.Mode.MULTIPLY); - + ((ProgressBar) this.loadingPanel.findViewById(R.id.progressBarLoadingPanel)) + .getIndeterminateDrawable().setColorFilter(Color.WHITE, PorterDuff.Mode.MULTIPLY); } + protected abstract void setupSubtitleView(@NonNull SubtitleView view, + @NonNull String captionSizeKey); + @Override public void initListeners() { super.initListeners(); playbackSeekBar.setOnSeekBarChangeListener(this); playbackSpeedTextView.setOnClickListener(this); qualityTextView.setOnClickListener(this); + captionTextView.setOnClickListener(this); + resizeView.setOnClickListener(this); + playbackLiveSync.setOnClickListener(this); } @Override public void initPlayer() { super.initPlayer(); + + // Setup video view simpleExoPlayer.setVideoSurfaceView(surfaceView); simpleExoPlayer.addVideoListener(this); + // Setup subtitle view + simpleExoPlayer.addTextOutput(cues -> subtitleView.onCues(cues)); + + // Setup audio session with onboard equalizer if (Build.VERSION.SDK_INT >= 21) { trackSelector.setTunnelingAudioSessionId(C.generateAudioSessionIdV21(context)); } @@ -217,9 +265,12 @@ public abstract class VideoPlayer extends BasePlayer qualityPopupMenu.getMenu().removeGroup(qualityPopupMenuGroupId); for (int i = 0; i < availableStreams.size(); i++) { VideoStream videoStream = availableStreams.get(i); - qualityPopupMenu.getMenu().add(qualityPopupMenuGroupId, i, Menu.NONE, MediaFormat.getNameById(videoStream.format) + " " + videoStream.resolution); + qualityPopupMenu.getMenu().add(qualityPopupMenuGroupId, i, Menu.NONE, + MediaFormat.getNameById(videoStream.getFormatId()) + " " + videoStream.resolution); + } + if (getSelectedVideoStream() != null) { + qualityTextView.setText(getSelectedVideoStream().resolution); } - qualityTextView.setText(getSelectedVideoStream().resolution); qualityPopupMenu.setOnMenuItemClickListener(this); qualityPopupMenu.setOnDismissListener(this); } @@ -236,6 +287,37 @@ public abstract class VideoPlayer extends BasePlayer playbackSpeedPopupMenu.setOnDismissListener(this); } + private void buildCaptionMenu(final List availableLanguages) { + if (captionPopupMenu == null) return; + captionPopupMenu.getMenu().removeGroup(captionPopupMenuGroupId); + + // Add option for turning off caption + MenuItem captionOffItem = captionPopupMenu.getMenu().add(captionPopupMenuGroupId, + 0, Menu.NONE, R.string.caption_none); + captionOffItem.setOnMenuItemClickListener(menuItem -> { + final int textRendererIndex = getRendererIndex(C.TRACK_TYPE_TEXT); + if (trackSelector != null && textRendererIndex != RENDERER_UNAVAILABLE) { + trackSelector.setRendererDisabled(textRendererIndex, true); + } + return true; + }); + + // Add all available captions + for (int i = 0; i < availableLanguages.size(); i++) { + final String captionLanguage = availableLanguages.get(i); + MenuItem captionItem = captionPopupMenu.getMenu().add(captionPopupMenuGroupId, + i + 1, Menu.NONE, captionLanguage); + captionItem.setOnMenuItemClickListener(menuItem -> { + final int textRendererIndex = getRendererIndex(C.TRACK_TYPE_TEXT); + if (trackSelector != null && textRendererIndex != RENDERER_UNAVAILABLE) { + trackSelector.setPreferredTextLanguage(captionLanguage); + trackSelector.setRendererDisabled(textRendererIndex, false); + } + return true; + }); + } + captionPopupMenu.setOnDismissListener(this); + } /*////////////////////////////////////////////////////////////////////////// // Playback Listener //////////////////////////////////////////////////////////////////////////*/ @@ -244,49 +326,121 @@ public abstract class VideoPlayer extends BasePlayer protected abstract int getOverrideResolutionIndex(final List sortedVideos, final String playbackQuality); - @Override - public void sync(@NonNull final PlayQueueItem item, @Nullable final StreamInfo info) { - super.sync(item, info); + protected void onMetadataChanged(@NonNull final PlayQueueItem item, + @Nullable final StreamInfo info, + final int newPlayQueueIndex, + final boolean hasPlayQueueItemChanged) { qualityTextView.setVisibility(View.GONE); playbackSpeedTextView.setVisibility(View.GONE); - if (info != null) { - final List videos = ListHelper.getSortedStreamVideosList(context, info.video_streams, info.video_only_streams, false); - availableStreams = new ArrayList<>(videos); - if (playbackQuality == null) { - selectedStreamIndex = getDefaultResolutionIndex(videos); - } else { - selectedStreamIndex = getOverrideResolutionIndex(videos, getPlaybackQuality()); - } + playbackEndTime.setVisibility(View.GONE); + playbackLiveSync.setVisibility(View.GONE); - buildQualityMenu(); - buildPlaybackSpeedMenu(); - qualityTextView.setVisibility(View.VISIBLE); - playbackSpeedTextView.setVisibility(View.VISIBLE); + final StreamType streamType = info == null ? StreamType.NONE : info.getStreamType(); + + switch (streamType) { + case AUDIO_STREAM: + surfaceView.setVisibility(View.GONE); + playbackEndTime.setVisibility(View.VISIBLE); + break; + + case AUDIO_LIVE_STREAM: + surfaceView.setVisibility(View.GONE); + playbackLiveSync.setVisibility(View.VISIBLE); + break; + + case LIVE_STREAM: + surfaceView.setVisibility(View.VISIBLE); + playbackLiveSync.setVisibility(View.VISIBLE); + break; + + case VIDEO_STREAM: + if (info.getVideoStreams().size() + info.getVideoOnlyStreams().size() == 0) break; + + final List videos = ListHelper.getSortedStreamVideosList(context, + info.getVideoStreams(), info.getVideoOnlyStreams(), false); + availableStreams = new ArrayList<>(videos); + if (playbackQuality == null) { + selectedStreamIndex = getDefaultResolutionIndex(videos); + } else { + selectedStreamIndex = getOverrideResolutionIndex(videos, getPlaybackQuality()); + } + + buildQualityMenu(); + qualityTextView.setVisibility(View.VISIBLE); + + surfaceView.setVisibility(View.VISIBLE); + default: + playbackEndTime.setVisibility(View.VISIBLE); + break; } + + buildPlaybackSpeedMenu(); + playbackSpeedTextView.setVisibility(View.VISIBLE); } @Override @Nullable public MediaSource sourceOf(final PlayQueueItem item, final StreamInfo info) { - final List videos = ListHelper.getSortedStreamVideosList(context, info.video_streams, info.video_only_streams, false); + final MediaSource liveSource = super.sourceOf(item, info); + if (liveSource != null) return liveSource; + List mediaSources = new ArrayList<>(); + + // Create video stream source + final List videos = ListHelper.getSortedStreamVideosList(context, + info.getVideoStreams(), info.getVideoOnlyStreams(), false); final int index; - if (playbackQuality == null) { + if (videos.isEmpty()) { + index = -1; + } else if (playbackQuality == null) { index = getDefaultResolutionIndex(videos); } else { index = getOverrideResolutionIndex(videos, getPlaybackQuality()); } - if (index < 0 || index >= videos.size()) return null; - final VideoStream video = videos.get(index); - - final MediaSource streamSource = buildMediaSource(video.getUrl(), MediaFormat.getSuffixById(video.format)); - final AudioStream audio = ListHelper.getHighestQualityAudio(info.audio_streams); - if (!video.isVideoOnly || audio == null) return streamSource; + final VideoStream video = index >= 0 && index < videos.size() ? videos.get(index) : null; + if (video != null) { + final MediaSource streamSource = buildMediaSource(video.getUrl(), + PlayerHelper.cacheKeyOf(info, video), + MediaFormat.getSuffixById(video.getFormatId())); + mediaSources.add(streamSource); + } + // Create optional audio stream source + final List audioStreams = info.getAudioStreams(); + final AudioStream audio = audioStreams.isEmpty() ? null : audioStreams.get( + ListHelper.getDefaultAudioFormat(context, audioStreams)); + // Use the audio stream if there is no video stream, or // Merge with audio stream in case if video does not contain audio - final MediaSource audioSource = buildMediaSource(audio.getUrl(), MediaFormat.getSuffixById(audio.format)); - return new MergingMediaSource(streamSource, audioSource); + if (audio != null && ((video != null && video.isVideoOnly) || video == null)) { + final MediaSource audioSource = buildMediaSource(audio.getUrl(), + PlayerHelper.cacheKeyOf(info, audio), + MediaFormat.getSuffixById(audio.getFormatId())); + mediaSources.add(audioSource); + } + + // If there is no audio or video sources, then this media source cannot be played back + if (mediaSources.isEmpty()) return null; + // Below are auxiliary media sources + + // Create subtitle sources + for (final Subtitles subtitle : info.getSubtitles()) { + final String mimeType = PlayerHelper.mimeTypesOf(subtitle.getFileType()); + if (mimeType == null) continue; + + final Format textFormat = Format.createTextSampleFormat(null, mimeType, + SELECTION_FLAG_AUTOSELECT, PlayerHelper.captionLanguageOf(context, subtitle)); + final MediaSource textSource = dataSource.getSampleMediaSourceFactory() + .createMediaSource(Uri.parse(subtitle.getURL()), textFormat, TIME_UNSET); + mediaSources.add(textSource); + } + + if (mediaSources.size() == 1) { + return mediaSources.get(0); + } else { + return new MergingMediaSource(mediaSources.toArray( + new MediaSource[mediaSources.size()])); + } } /*////////////////////////////////////////////////////////////////////////// @@ -298,7 +452,7 @@ public abstract class VideoPlayer extends BasePlayer super.onBlocked(); controlsVisibilityHandler.removeCallbacksAndMessages(null); - animateView(controlsRoot, false, 300); + animateView(controlsRoot, false, DEFAULT_CONTROLS_DURATION); playbackSeekBar.setEnabled(false); // Bug on lower api, disabling and enabling the seekBar resets the thumb color -.-, so sets the color again @@ -323,7 +477,7 @@ public abstract class VideoPlayer extends BasePlayer playbackSeekBar.getThumb().setColorFilter(Color.RED, PorterDuff.Mode.SRC_IN); loadingPanel.setVisibility(View.GONE); - showControlsThenHide(); + animateView(currentDisplaySeek, AnimationUtils.Type.SCALE_AND_ALPHA, false, 200); animateView(endScreen, false, 0); } @@ -364,6 +518,18 @@ public abstract class VideoPlayer extends BasePlayer // ExoPlayer Video Listener //////////////////////////////////////////////////////////////////////////*/ + @Override + public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + super.onTracksChanged(trackGroups, trackSelections); + onTextTrackUpdate(); + } + + @Override + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + super.onPlaybackParametersChanged(playbackParameters); + playbackSpeedTextView.setText(formatSpeed(playbackParameters.speed)); + } + @Override public void onVideoSizeChanged(int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) { if (DEBUG) { @@ -377,6 +543,46 @@ public abstract class VideoPlayer extends BasePlayer animateView(surfaceForeground, false, 100); } + /*////////////////////////////////////////////////////////////////////////// + // ExoPlayer Track Updates + //////////////////////////////////////////////////////////////////////////*/ + + private void onTextTrackUpdate() { + final int textRenderer = getRendererIndex(C.TRACK_TYPE_TEXT); + + if (captionTextView == null) return; + if (trackSelector == null || trackSelector.getCurrentMappedTrackInfo() == null || + textRenderer == RENDERER_UNAVAILABLE) { + captionTextView.setVisibility(View.GONE); + return; + } + + final TrackGroupArray textTracks = trackSelector.getCurrentMappedTrackInfo() + .getTrackGroups(textRenderer); + + // Extract all loaded languages + List availableLanguages = new ArrayList<>(textTracks.length); + for (int i = 0; i < textTracks.length; i++) { + final TrackGroup textTrack = textTracks.get(i); + if (textTrack.length > 0 && textTrack.getFormat(0) != null) { + availableLanguages.add(textTrack.getFormat(0).language); + } + } + + // Normalize mismatching language strings + final String preferredLanguage = trackSelector.getPreferredTextLanguage(); + + // Build UI + buildCaptionMenu(availableLanguages); + if (trackSelector.getRendererDisabled(textRenderer) || preferredLanguage == null || + !availableLanguages.contains(preferredLanguage)) { + captionTextView.setText(R.string.caption_none); + } else { + captionTextView.setText(preferredLanguage); + } + captionTextView.setVisibility(availableLanguages.isEmpty() ? View.GONE : View.VISIBLE); + } + /*////////////////////////////////////////////////////////////////////////// // General Player //////////////////////////////////////////////////////////////////////////*/ @@ -400,7 +606,7 @@ public abstract class VideoPlayer extends BasePlayer @Override public void onUpdateProgress(int currentProgress, int duration, int bufferPercent) { - if (!isPrepared) return; + if (!isPrepared()) return; if (duration != playbackSeekBar.getMax()) { playbackEndTime.setText(getTimeString(duration)); @@ -416,17 +622,16 @@ public abstract class VideoPlayer extends BasePlayer if (DEBUG && bufferPercent % 20 == 0) { //Limit log Log.d(TAG, "updateProgress() called with: isVisible = " + isControlsVisible() + ", currentProgress = [" + currentProgress + "], duration = [" + duration + "], bufferPercent = [" + bufferPercent + "]"); } + playbackLiveSync.setClickable(!isLiveEdge()); } @Override - public void onThumbnailReceived(Bitmap thumbnail) { - super.onThumbnailReceived(thumbnail); - if (thumbnail != null) endScreen.setImageBitmap(thumbnail); + public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) { + super.onLoadingComplete(imageUri, view, loadedImage); + if (loadedImage != null) endScreen.setImageBitmap(loadedImage); } protected void onFullScreenButtonClicked() { - if (!isPlayerReady()) return; - changeState(STATE_BLOCKED); } @@ -453,6 +658,12 @@ public abstract class VideoPlayer extends BasePlayer onQualitySelectorClicked(); } else if (v.getId() == playbackSpeedTextView.getId()) { onPlaybackSpeedClicked(); + } else if (v.getId() == resizeView.getId()) { + onResizeClicked(); + } else if (v.getId() == captionTextView.getId()) { + onCaptionClicked(); + } else if (v.getId() == playbackLiveSync.getId()) { + seekToDefault(); } } @@ -494,28 +705,51 @@ public abstract class VideoPlayer extends BasePlayer public void onDismiss(PopupMenu menu) { if (DEBUG) Log.d(TAG, "onDismiss() called with: menu = [" + menu + "]"); isSomePopupMenuVisible = false; - qualityTextView.setText(getSelectedVideoStream().resolution); + if (getSelectedVideoStream() != null) { + qualityTextView.setText(getSelectedVideoStream().resolution); + } } public void onQualitySelectorClicked() { if (DEBUG) Log.d(TAG, "onQualitySelectorClicked() called"); qualityPopupMenu.show(); isSomePopupMenuVisible = true; - showControls(300); + showControls(DEFAULT_CONTROLS_DURATION); final VideoStream videoStream = getSelectedVideoStream(); - final String qualityText = MediaFormat.getNameById(videoStream.format) + " " + videoStream.resolution; - qualityTextView.setText(qualityText); + if (videoStream != null) { + final String qualityText = MediaFormat.getNameById(videoStream.getFormatId()) + " " + + videoStream.resolution; + qualityTextView.setText(qualityText); + } + wasPlaying = simpleExoPlayer.getPlayWhenReady(); } - private void onPlaybackSpeedClicked() { + public void onPlaybackSpeedClicked() { if (DEBUG) Log.d(TAG, "onPlaybackSpeedClicked() called"); playbackSpeedPopupMenu.show(); isSomePopupMenuVisible = true; - showControls(300); + showControls(DEFAULT_CONTROLS_DURATION); } + private void onCaptionClicked() { + if (DEBUG) Log.d(TAG, "onCaptionClicked() called"); + captionPopupMenu.show(); + isSomePopupMenuVisible = true; + showControls(DEFAULT_CONTROLS_DURATION); + } + + private void onResizeClicked() { + if (getAspectRatioFrameLayout() != null) { + final int currentResizeMode = getAspectRatioFrameLayout().getResizeMode(); + final int newResizeMode = nextResizeMode(currentResizeMode); + getAspectRatioFrameLayout().setResizeMode(newResizeMode); + getResizeView().setText(PlayerHelper.resizeTypeOf(context, newResizeMode)); + } + } + + protected abstract int nextResizeMode(@AspectRatioFrameLayout.ResizeMode final int resizeMode); /*////////////////////////////////////////////////////////////////////////// // SeekBar Listener //////////////////////////////////////////////////////////////////////////*/ @@ -536,14 +770,15 @@ public abstract class VideoPlayer extends BasePlayer if (isPlaying()) simpleExoPlayer.setPlayWhenReady(false); showControls(0); - animateView(currentDisplaySeek, AnimationUtils.Type.SCALE_AND_ALPHA, true, 300); + animateView(currentDisplaySeek, AnimationUtils.Type.SCALE_AND_ALPHA, true, + DEFAULT_CONTROLS_DURATION); } @Override public void onStopTrackingTouch(SeekBar seekBar) { if (DEBUG) Log.d(TAG, "onStopTrackingTouch() called with: seekBar = [" + seekBar + "]"); - simpleExoPlayer.seekTo(seekBar.getProgress()); + seekTo(seekBar.getProgress()); if (wasPlaying || simpleExoPlayer.getDuration() == seekBar.getProgress()) simpleExoPlayer.setPlayWhenReady(true); playbackCurrentTime.setText(getTimeString(seekBar.getProgress())); @@ -557,16 +792,16 @@ public abstract class VideoPlayer extends BasePlayer // Utils //////////////////////////////////////////////////////////////////////////*/ - public int getVideoRendererIndex() { - if (simpleExoPlayer == null) return -1; + public int getRendererIndex(final int trackIndex) { + if (simpleExoPlayer == null) return RENDERER_UNAVAILABLE; for (int t = 0; t < simpleExoPlayer.getRendererCount(); t++) { - if (simpleExoPlayer.getRendererType(t) == C.TRACK_TYPE_VIDEO) { + if (simpleExoPlayer.getRendererType(t) == trackIndex) { return t; } } - return -1; + return RENDERER_UNAVAILABLE; } public boolean isControlsVisible() { @@ -592,7 +827,7 @@ public abstract class VideoPlayer extends BasePlayer PropertyValuesHolder.ofFloat(View.ALPHA, 1f, 0f), PropertyValuesHolder.ofFloat(View.SCALE_X, 1.4f, 1f), PropertyValuesHolder.ofFloat(View.SCALE_Y, 1.4f, 1f) - ).setDuration(300); + ).setDuration(DEFAULT_CONTROLS_DURATION); controlViewAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { @@ -634,12 +869,8 @@ public abstract class VideoPlayer extends BasePlayer public void showControlsThenHide() { if (DEBUG) Log.d(TAG, "showControlsThenHide() called"); - animateView(controlsRoot, true, 300, 0, new Runnable() { - @Override - public void run() { - hideControls(300, DEFAULT_CONTROLS_HIDE_TIME); - } - }); + animateView(controlsRoot, true, DEFAULT_CONTROLS_DURATION, 0, + () -> hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME)); } public void showControls(long duration) { @@ -651,12 +882,8 @@ public abstract class VideoPlayer extends BasePlayer public void hideControls(final long duration, long delay) { if (DEBUG) Log.d(TAG, "hideControls() called with: delay = [" + delay + "]"); controlsVisibilityHandler.removeCallbacksAndMessages(null); - controlsVisibilityHandler.postDelayed(new Runnable() { - @Override - public void run() { - animateView(controlsRoot, false, duration); - } - }, delay); + controlsVisibilityHandler.postDelayed( + () -> animateView(controlsRoot, false, duration), delay); } /*////////////////////////////////////////////////////////////////////////// @@ -683,8 +910,11 @@ public abstract class VideoPlayer extends BasePlayer return wasPlaying; } + @Nullable public VideoStream getSelectedVideoStream() { - return availableStreams.get(selectedStreamIndex); + return (selectedStreamIndex >= 0 && availableStreams != null && + availableStreams.size() > selectedStreamIndex) ? + availableStreams.get(selectedStreamIndex) : null; } public Handler getControlsVisibilityHandler() { @@ -755,4 +985,15 @@ public abstract class VideoPlayer extends BasePlayer return currentDisplaySeek; } + public SubtitleView getSubtitleView() { + return subtitleView; + } + + public TextView getResizeView() { + return resizeView; + } + + public TextView getCaptionTextView() { + return captionTextView; + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java index 4e031a0dd..5b9cce947 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java @@ -17,10 +17,14 @@ import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.audio.AudioRendererEventListener; import com.google.android.exoplayer2.decoder.DecoderCounters; -public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, AudioRendererEventListener { +public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, + AudioRendererEventListener { private static final String TAG = "AudioFocusReactor"; + private static final boolean SHOULD_BUILD_FOCUS_REQUEST = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O; + private static final int DUCK_DURATION = 1500; private static final float DUCK_AUDIO_TO = .2f; @@ -33,13 +37,14 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, Au private final AudioFocusRequest request; - public AudioReactor(@NonNull final Context context, @NonNull final SimpleExoPlayer player) { + public AudioReactor(@NonNull final Context context, + @NonNull final SimpleExoPlayer player) { this.player = player; this.context = context; this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); - player.setAudioDebugListener(this); + player.addAudioDebugListener(this); - if (shouldBuildFocusRequest()) { + if (SHOULD_BUILD_FOCUS_REQUEST) { request = new AudioFocusRequest.Builder(FOCUS_GAIN_TYPE) .setAcceptsDelayedFocusGain(true) .setWillPauseWhenDucked(true) @@ -50,12 +55,17 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, Au } } + public void dispose() { + abandonAudioFocus(); + player.removeAudioDebugListener(this); + } + /*////////////////////////////////////////////////////////////////////////// // Audio Manager //////////////////////////////////////////////////////////////////////////*/ public void requestAudioFocus() { - if (shouldBuildFocusRequest()) { + if (SHOULD_BUILD_FOCUS_REQUEST) { audioManager.requestAudioFocus(request); } else { audioManager.requestAudioFocus(this, STREAM_TYPE, FOCUS_GAIN_TYPE); @@ -63,7 +73,7 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, Au } public void abandonAudioFocus() { - if (shouldBuildFocusRequest()) { + if (SHOULD_BUILD_FOCUS_REQUEST) { audioManager.abandonAudioFocusRequest(request); } else { audioManager.abandonAudioFocus(this); @@ -82,10 +92,6 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, Au audioManager.setStreamVolume(STREAM_TYPE, volume, 0); } - private boolean shouldBuildFocusRequest() { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O; - } - /*////////////////////////////////////////////////////////////////////////// // AudioFocus //////////////////////////////////////////////////////////////////////////*/ @@ -148,12 +154,8 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, Au player.setVolume(to); } }); - valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { - @Override - public void onAnimationUpdate(ValueAnimator animation) { - player.setVolume(((float) animation.getAnimatedValue())); - } - }); + valueAnimator.addUpdateListener(animation -> + player.setVolume(((float) animation.getAnimatedValue()))); valueAnimator.start(); } @@ -181,7 +183,9 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, Au public void onAudioInputFormatChanged(Format format) {} @Override - public void onAudioTrackUnderrun(int i, long l, long l1) {} + public void onAudioSinkUnderrun(int bufferSize, + long bufferSizeMs, + long elapsedSinceLastFeedMs) {} @Override public void onAudioDisabled(DecoderCounters decoderCounters) {} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java index dce74ffb5..ec7813056 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java @@ -4,11 +4,14 @@ import android.content.Context; import android.support.annotation.NonNull; import android.util.Log; +import com.google.android.exoplayer2.upstream.BandwidthMeter; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; import com.google.android.exoplayer2.upstream.DefaultDataSource; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; import com.google.android.exoplayer2.upstream.FileDataSource; +import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.upstream.cache.CacheDataSink; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; @@ -18,7 +21,7 @@ import org.schabi.newpipe.Downloader; import java.io.File; -public class CacheFactory implements DataSource.Factory { +/* package-private */ class CacheFactory implements DataSource.Factory { private static final String TAG = "CacheFactory"; private static final String CACHE_FOLDER_NAME = "exoplayer"; private static final int CACHE_FLAGS = CacheDataSource.FLAG_BLOCK_ON_CACHE | CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; @@ -33,18 +36,21 @@ public class CacheFactory implements DataSource.Factory { // todo: make this a singleton? private static SimpleCache cache; - public CacheFactory(@NonNull final Context context) { - this(context, PlayerHelper.getPreferredCacheSize(context), PlayerHelper.getPreferredFileSize(context)); + public CacheFactory(@NonNull final Context context, + @NonNull final String userAgent, + @NonNull final TransferListener transferListener) { + this(context, userAgent, transferListener, PlayerHelper.getPreferredCacheSize(context), + PlayerHelper.getPreferredFileSize(context)); } - CacheFactory(@NonNull final Context context, final long maxCacheSize, final long maxFileSize) { - super(); + private CacheFactory(@NonNull final Context context, + @NonNull final String userAgent, + @NonNull final TransferListener transferListener, + final long maxCacheSize, + final long maxFileSize) { this.maxFileSize = maxFileSize; - final String userAgent = Downloader.USER_AGENT; - final DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter(); - dataSourceFactory = new DefaultDataSourceFactory(context, userAgent, bandwidthMeter); - + dataSourceFactory = new DefaultDataSourceFactory(context, userAgent, transferListener); cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); if (!cacheDir.exists()) { //noinspection ResultOfMethodCallIgnored diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java index acc20f5b0..7670deb98 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java @@ -2,6 +2,7 @@ package org.schabi.newpipe.player.helper; import android.content.Context; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.DefaultLoadControl; import com.google.android.exoplayer2.LoadControl; import com.google.android.exoplayer2.Renderer; @@ -10,10 +11,14 @@ import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.DefaultAllocator; +import static com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS; +import static com.google.android.exoplayer2.DefaultLoadControl.DEFAULT_TARGET_BUFFER_BYTES; + public class LoadController implements LoadControl { public static final String TAG = "LoadController"; + private final long initialPlaybackBufferUs; private final LoadControl internalLoadControl; /*////////////////////////////////////////////////////////////////////////// @@ -21,18 +26,25 @@ public class LoadController implements LoadControl { //////////////////////////////////////////////////////////////////////////*/ public LoadController(final Context context) { - this(PlayerHelper.getMinBufferMs(context), - PlayerHelper.getMaxBufferMs(context), - PlayerHelper.getBufferForPlaybackMs(context), - PlayerHelper.getBufferForPlaybackAfterRebufferMs(context)); + this(PlayerHelper.getPlaybackStartBufferMs(context), + PlayerHelper.getPlaybackMinimumBufferMs(context), + PlayerHelper.getPlaybackOptimalBufferMs(context)); } - public LoadController(final int minBufferMs, - final int maxBufferMs, - final long bufferForPlaybackMs, - final long bufferForPlaybackAfterRebufferMs) { - final DefaultAllocator allocator = new DefaultAllocator(true, 65536); - internalLoadControl = new DefaultLoadControl(allocator, minBufferMs, maxBufferMs, bufferForPlaybackMs, bufferForPlaybackAfterRebufferMs); + private LoadController(final int initialPlaybackBufferMs, + final int minimumPlaybackbufferMs, + final int optimalPlaybackBufferMs) { + this.initialPlaybackBufferUs = initialPlaybackBufferMs * 1000; + + final DefaultAllocator allocator = new DefaultAllocator(true, + C.DEFAULT_BUFFER_SEGMENT_SIZE); + + internalLoadControl = new DefaultLoadControl(allocator, + /*minBufferMs=*/minimumPlaybackbufferMs, + /*maxBufferMs=*/optimalPlaybackBufferMs, + /*bufferForPlaybackMs=*/initialPlaybackBufferMs, + /*bufferForPlaybackAfterRebufferMs=*/initialPlaybackBufferMs, + DEFAULT_TARGET_BUFFER_BYTES, DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS); } /*////////////////////////////////////////////////////////////////////////// @@ -45,7 +57,8 @@ public class LoadController implements LoadControl { } @Override - public void onTracksSelected(Renderer[] renderers, TrackGroupArray trackGroupArray, TrackSelectionArray trackSelectionArray) { + public void onTracksSelected(Renderer[] renderers, TrackGroupArray trackGroupArray, + TrackSelectionArray trackSelectionArray) { internalLoadControl.onTracksSelected(renderers, trackGroupArray, trackSelectionArray); } @@ -65,12 +78,27 @@ public class LoadController implements LoadControl { } @Override - public boolean shouldStartPlayback(long l, boolean b) { - return internalLoadControl.shouldStartPlayback(l, b); + public long getBackBufferDurationUs() { + return internalLoadControl.getBackBufferDurationUs(); } @Override - public boolean shouldContinueLoading(long l) { - return internalLoadControl.shouldContinueLoading(l); + public boolean retainBackBufferFromKeyframe() { + return internalLoadControl.retainBackBufferFromKeyframe(); + } + + @Override + public boolean shouldContinueLoading(long bufferedDurationUs, float playbackSpeed) { + return internalLoadControl.shouldContinueLoading(bufferedDurationUs, playbackSpeed); + } + + @Override + public boolean shouldStartPlayback(long bufferedDurationUs, float playbackSpeed, + boolean rebuffering) { + final boolean isInitialPlaybackBufferFilled = bufferedDurationUs >= + this.initialPlaybackBufferUs * playbackSpeed; + final boolean isInternalStartingPlayback = internalLoadControl.shouldStartPlayback( + bufferedDurationUs, playbackSpeed, rebuffering); + return isInitialPlaybackBufferFilled || isInternalStartingPlayback; } } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java b/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java new file mode 100644 index 000000000..8405e45fd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java @@ -0,0 +1,38 @@ +package org.schabi.newpipe.player.helper; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.v4.media.session.MediaSessionCompat; + +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; + +import org.schabi.newpipe.player.mediasession.DummyPlaybackPreparer; +import org.schabi.newpipe.player.mediasession.MediaSessionCallback; +import org.schabi.newpipe.player.mediasession.PlayQueueNavigator; +import org.schabi.newpipe.player.mediasession.PlayQueuePlaybackController; + +public class MediaSessionManager { + private static final String TAG = "MediaSessionManager"; + + private final MediaSessionCompat mediaSession; + private final MediaSessionConnector sessionConnector; + + public MediaSessionManager(@NonNull final Context context, + @NonNull final Player player, + @NonNull final MediaSessionCallback callback) { + this.mediaSession = new MediaSessionCompat(context, TAG); + this.sessionConnector = new MediaSessionConnector(mediaSession, + new PlayQueuePlaybackController(callback)); + this.sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, callback)); + this.sessionConnector.setPlayer(player, new DummyPlaybackPreparer()); + } + + public MediaSessionCompat getMediaSession() { + return mediaSession; + } + + public MediaSessionConnector getSessionConnector() { + return sessionConnector; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java new file mode 100644 index 000000000..7c7d87791 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java @@ -0,0 +1,379 @@ +package org.schabi.newpipe.player.helper; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.app.DialogFragment; +import android.support.v7.app.AlertDialog; +import android.util.Log; +import android.view.View; +import android.widget.CheckBox; +import android.widget.SeekBar; +import android.widget.TextView; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.util.SliderStrategy; + +import static org.schabi.newpipe.player.BasePlayer.DEBUG; + +public class PlaybackParameterDialog extends DialogFragment { + @NonNull private static final String TAG = "PlaybackParameterDialog"; + + public static final double MINIMUM_PLAYBACK_VALUE = 0.25f; + public static final double MAXIMUM_PLAYBACK_VALUE = 3.00f; + + public static final char STEP_UP_SIGN = '+'; + public static final char STEP_DOWN_SIGN = '-'; + public static final double PLAYBACK_STEP_VALUE = 0.05f; + + public static final double NIGHTCORE_TEMPO = 1.20f; + public static final double NIGHTCORE_PITCH_LOWER = 1.15f; + public static final double NIGHTCORE_PITCH_UPPER = 1.25f; + + public static final double DEFAULT_TEMPO = 1.00f; + public static final double DEFAULT_PITCH = 1.00f; + + @NonNull private static final String INITIAL_TEMPO_KEY = "initial_tempo_key"; + @NonNull private static final String INITIAL_PITCH_KEY = "initial_pitch_key"; + + public interface Callback { + void onPlaybackParameterChanged(final float playbackTempo, final float playbackPitch); + } + + @Nullable private Callback callback; + + @NonNull private final SliderStrategy strategy = new SliderStrategy.Quadratic( + MINIMUM_PLAYBACK_VALUE, MAXIMUM_PLAYBACK_VALUE, + /*centerAt=*/1.00f, /*sliderGranularity=*/10000); + + private double initialTempo = DEFAULT_TEMPO; + private double initialPitch = DEFAULT_PITCH; + + @Nullable private SeekBar tempoSlider; + @Nullable private TextView tempoMinimumText; + @Nullable private TextView tempoMaximumText; + @Nullable private TextView tempoCurrentText; + @Nullable private TextView tempoStepDownText; + @Nullable private TextView tempoStepUpText; + + @Nullable private SeekBar pitchSlider; + @Nullable private TextView pitchMinimumText; + @Nullable private TextView pitchMaximumText; + @Nullable private TextView pitchCurrentText; + @Nullable private TextView pitchStepDownText; + @Nullable private TextView pitchStepUpText; + + @Nullable private CheckBox unhookingCheckbox; + + @Nullable private TextView nightCorePresetText; + @Nullable private TextView resetPresetText; + + public static PlaybackParameterDialog newInstance(final double playbackTempo, + final double playbackPitch) { + PlaybackParameterDialog dialog = new PlaybackParameterDialog(); + dialog.initialTempo = playbackTempo; + dialog.initialPitch = playbackPitch; + return dialog; + } + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onAttach(Context context) { + super.onAttach(context); + if (context != null && context instanceof Callback) { + callback = (Callback) context; + } else { + dismiss(); + } + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (savedInstanceState != null) { + initialTempo = savedInstanceState.getDouble(INITIAL_TEMPO_KEY, DEFAULT_TEMPO); + initialPitch = savedInstanceState.getDouble(INITIAL_PITCH_KEY, DEFAULT_PITCH); + } + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + outState.putDouble(INITIAL_TEMPO_KEY, initialTempo); + outState.putDouble(INITIAL_PITCH_KEY, initialPitch); + } + + /*////////////////////////////////////////////////////////////////////////// + // Dialog + //////////////////////////////////////////////////////////////////////////*/ + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + final View view = View.inflate(getContext(), R.layout.dialog_playback_parameter, null); + setupControlViews(view); + + final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(requireActivity()) + .setTitle(R.string.playback_speed_control) + .setView(view) + .setCancelable(true) + .setNegativeButton(R.string.cancel, (dialogInterface, i) -> + setPlaybackParameters(initialTempo, initialPitch)) + .setPositiveButton(R.string.finish, (dialogInterface, i) -> + setCurrentPlaybackParameters()); + + return dialogBuilder.create(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Control Views + //////////////////////////////////////////////////////////////////////////*/ + + private void setupControlViews(@NonNull View rootView) { + setupHookingControl(rootView); + setupTempoControl(rootView); + setupPitchControl(rootView); + setupPresetControl(rootView); + } + + private void setupTempoControl(@NonNull View rootView) { + tempoSlider = rootView.findViewById(R.id.tempoSeekbar); + tempoMinimumText = rootView.findViewById(R.id.tempoMinimumText); + tempoMaximumText = rootView.findViewById(R.id.tempoMaximumText); + tempoCurrentText = rootView.findViewById(R.id.tempoCurrentText); + tempoStepUpText = rootView.findViewById(R.id.tempoStepUp); + tempoStepDownText = rootView.findViewById(R.id.tempoStepDown); + + if (tempoCurrentText != null) + tempoCurrentText.setText(PlayerHelper.formatSpeed(initialTempo)); + if (tempoMaximumText != null) + tempoMaximumText.setText(PlayerHelper.formatSpeed(MAXIMUM_PLAYBACK_VALUE)); + if (tempoMinimumText != null) + tempoMinimumText.setText(PlayerHelper.formatSpeed(MINIMUM_PLAYBACK_VALUE)); + + if (tempoStepUpText != null) { + tempoStepUpText.setText(getStepUpPercentString(PLAYBACK_STEP_VALUE)); + tempoStepUpText.setOnClickListener(view -> { + onTempoSliderUpdated(getCurrentTempo() + PLAYBACK_STEP_VALUE); + setCurrentPlaybackParameters(); + }); + } + + if (tempoStepDownText != null) { + tempoStepDownText.setText(getStepDownPercentString(PLAYBACK_STEP_VALUE)); + tempoStepDownText.setOnClickListener(view -> { + onTempoSliderUpdated(getCurrentTempo() - PLAYBACK_STEP_VALUE); + setCurrentPlaybackParameters(); + }); + } + + if (tempoSlider != null) { + tempoSlider.setMax(strategy.progressOf(MAXIMUM_PLAYBACK_VALUE)); + tempoSlider.setProgress(strategy.progressOf(initialTempo)); + tempoSlider.setOnSeekBarChangeListener(getOnTempoChangedListener()); + } + } + + private void setupPitchControl(@NonNull View rootView) { + pitchSlider = rootView.findViewById(R.id.pitchSeekbar); + pitchMinimumText = rootView.findViewById(R.id.pitchMinimumText); + pitchMaximumText = rootView.findViewById(R.id.pitchMaximumText); + pitchCurrentText = rootView.findViewById(R.id.pitchCurrentText); + pitchStepDownText = rootView.findViewById(R.id.pitchStepDown); + pitchStepUpText = rootView.findViewById(R.id.pitchStepUp); + + if (pitchCurrentText != null) + pitchCurrentText.setText(PlayerHelper.formatPitch(initialPitch)); + if (pitchMaximumText != null) + pitchMaximumText.setText(PlayerHelper.formatPitch(MAXIMUM_PLAYBACK_VALUE)); + if (pitchMinimumText != null) + pitchMinimumText.setText(PlayerHelper.formatPitch(MINIMUM_PLAYBACK_VALUE)); + + if (pitchStepUpText != null) { + pitchStepUpText.setText(getStepUpPercentString(PLAYBACK_STEP_VALUE)); + pitchStepUpText.setOnClickListener(view -> { + onPitchSliderUpdated(getCurrentPitch() + PLAYBACK_STEP_VALUE); + setCurrentPlaybackParameters(); + }); + } + + if (pitchStepDownText != null) { + pitchStepDownText.setText(getStepDownPercentString(PLAYBACK_STEP_VALUE)); + pitchStepDownText.setOnClickListener(view -> { + onPitchSliderUpdated(getCurrentPitch() - PLAYBACK_STEP_VALUE); + setCurrentPlaybackParameters(); + }); + } + + if (pitchSlider != null) { + pitchSlider.setMax(strategy.progressOf(MAXIMUM_PLAYBACK_VALUE)); + pitchSlider.setProgress(strategy.progressOf(initialPitch)); + pitchSlider.setOnSeekBarChangeListener(getOnPitchChangedListener()); + } + } + + private void setupHookingControl(@NonNull View rootView) { + unhookingCheckbox = rootView.findViewById(R.id.unhookCheckbox); + if (unhookingCheckbox != null) { + unhookingCheckbox.setChecked(initialPitch != initialTempo); + unhookingCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> { + if (isChecked) return; + // When unchecked, slide back to the minimum of current tempo or pitch + final double minimum = Math.min(getCurrentPitch(), getCurrentTempo()); + setSliders(minimum); + setCurrentPlaybackParameters(); + }); + } + } + + private void setupPresetControl(@NonNull View rootView) { + nightCorePresetText = rootView.findViewById(R.id.presetNightcore); + if (nightCorePresetText != null) { + nightCorePresetText.setOnClickListener(view -> { + final double randomPitch = NIGHTCORE_PITCH_LOWER + + Math.random() * (NIGHTCORE_PITCH_UPPER - NIGHTCORE_PITCH_LOWER); + + setTempoSlider(NIGHTCORE_TEMPO); + setPitchSlider(randomPitch); + setCurrentPlaybackParameters(); + }); + } + + resetPresetText = rootView.findViewById(R.id.presetReset); + if (resetPresetText != null) { + resetPresetText.setOnClickListener(view -> { + setTempoSlider(DEFAULT_TEMPO); + setPitchSlider(DEFAULT_PITCH); + setCurrentPlaybackParameters(); + }); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Sliders + //////////////////////////////////////////////////////////////////////////*/ + + private SeekBar.OnSeekBarChangeListener getOnTempoChangedListener() { + return new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + final double currentTempo = strategy.valueOf(progress); + if (fromUser) { + onTempoSliderUpdated(currentTempo); + setCurrentPlaybackParameters(); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + // Do Nothing. + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + // Do Nothing. + } + }; + } + + private SeekBar.OnSeekBarChangeListener getOnPitchChangedListener() { + return new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + final double currentPitch = strategy.valueOf(progress); + if (fromUser) { // this change is first in chain + onPitchSliderUpdated(currentPitch); + setCurrentPlaybackParameters(); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + // Do Nothing. + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + // Do Nothing. + } + }; + } + + private void onTempoSliderUpdated(final double newTempo) { + if (unhookingCheckbox == null) return; + if (!unhookingCheckbox.isChecked()) { + setSliders(newTempo); + } else { + setTempoSlider(newTempo); + } + } + + private void onPitchSliderUpdated(final double newPitch) { + if (unhookingCheckbox == null) return; + if (!unhookingCheckbox.isChecked()) { + setSliders(newPitch); + } else { + setPitchSlider(newPitch); + } + } + + private void setSliders(final double newValue) { + setTempoSlider(newValue); + setPitchSlider(newValue); + } + + private void setTempoSlider(final double newTempo) { + if (tempoSlider == null) return; + tempoSlider.setProgress(strategy.progressOf(newTempo)); + } + + private void setPitchSlider(final double newPitch) { + if (pitchSlider == null) return; + pitchSlider.setProgress(strategy.progressOf(newPitch)); + } + + /*////////////////////////////////////////////////////////////////////////// + // Helper + //////////////////////////////////////////////////////////////////////////*/ + + private void setCurrentPlaybackParameters() { + setPlaybackParameters(getCurrentTempo(), getCurrentPitch()); + } + + private void setPlaybackParameters(final double tempo, final double pitch) { + if (callback != null && tempoCurrentText != null && pitchCurrentText != null) { + if (DEBUG) Log.d(TAG, "Setting playback parameters to " + + "tempo=[" + tempo + "], " + + "pitch=[" + pitch + "]"); + + tempoCurrentText.setText(PlayerHelper.formatSpeed(tempo)); + pitchCurrentText.setText(PlayerHelper.formatPitch(pitch)); + callback.onPlaybackParameterChanged((float) tempo, (float) pitch); + } + } + + private double getCurrentTempo() { + return tempoSlider == null ? initialTempo : strategy.valueOf( + tempoSlider.getProgress()); + } + + private double getCurrentPitch() { + return pitchSlider == null ? initialPitch : strategy.valueOf( + pitchSlider.getProgress()); + } + + @NonNull + private static String getStepUpPercentString(final double percent) { + return STEP_UP_SIGN + PlayerHelper.formatPitch(percent); + } + + @NonNull + private static String getStepDownPercentString(final double percent) { + return STEP_DOWN_SIGN + PlayerHelper.formatPitch(percent); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java new file mode 100644 index 000000000..133121269 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java @@ -0,0 +1,78 @@ +package org.schabi.newpipe.player.helper; + +import android.content.Context; +import android.support.annotation.NonNull; + +import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.source.SingleSampleMediaSource; +import com.google.android.exoplayer2.source.dash.DashMediaSource; +import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource; +import com.google.android.exoplayer2.source.hls.HlsMediaSource; +import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource; +import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.TransferListener; + +public class PlayerDataSource { + private static final int MANIFEST_MINIMUM_RETRY = 5; + private static final int EXTRACTOR_MINIMUM_RETRY = Integer.MAX_VALUE; + private static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000; + + private final DataSource.Factory cacheDataSourceFactory; + private final DataSource.Factory cachelessDataSourceFactory; + + public PlayerDataSource(@NonNull final Context context, + @NonNull final String userAgent, + @NonNull final TransferListener transferListener) { + cacheDataSourceFactory = new CacheFactory(context, userAgent, transferListener); + cachelessDataSourceFactory = new DefaultDataSourceFactory(context, userAgent, transferListener); + } + + public SsMediaSource.Factory getLiveSsMediaSourceFactory() { + return new SsMediaSource.Factory(new DefaultSsChunkSource.Factory( + cachelessDataSourceFactory), cachelessDataSourceFactory) + .setMinLoadableRetryCount(MANIFEST_MINIMUM_RETRY) + .setLivePresentationDelayMs(LIVE_STREAM_EDGE_GAP_MILLIS); + } + + public HlsMediaSource.Factory getLiveHlsMediaSourceFactory() { + return new HlsMediaSource.Factory(cachelessDataSourceFactory) + .setAllowChunklessPreparation(true) + .setMinLoadableRetryCount(MANIFEST_MINIMUM_RETRY); + } + + public DashMediaSource.Factory getLiveDashMediaSourceFactory() { + return new DashMediaSource.Factory(new DefaultDashChunkSource.Factory( + cachelessDataSourceFactory), cachelessDataSourceFactory) + .setMinLoadableRetryCount(MANIFEST_MINIMUM_RETRY) + .setLivePresentationDelayMs(LIVE_STREAM_EDGE_GAP_MILLIS); + } + + public SsMediaSource.Factory getSsMediaSourceFactory() { + return new SsMediaSource.Factory(new DefaultSsChunkSource.Factory( + cacheDataSourceFactory), cacheDataSourceFactory); + } + + public HlsMediaSource.Factory getHlsMediaSourceFactory() { + return new HlsMediaSource.Factory(cacheDataSourceFactory); + } + + public DashMediaSource.Factory getDashMediaSourceFactory() { + return new DashMediaSource.Factory(new DefaultDashChunkSource.Factory( + cacheDataSourceFactory), cacheDataSourceFactory); + } + + public ExtractorMediaSource.Factory getExtractorMediaSourceFactory() { + return new ExtractorMediaSource.Factory(cacheDataSourceFactory) + .setMinLoadableRetryCount(EXTRACTOR_MINIMUM_RETRY); + } + + public ExtractorMediaSource.Factory getExtractorMediaSourceFactory(@NonNull final String key) { + return getExtractorMediaSourceFactory().setCustomCacheKey(key); + } + + public SingleSampleMediaSource.Factory getSampleMediaSourceFactory() { + return new SingleSampleMediaSource.Factory(cacheDataSourceFactory); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java index 40063ba40..63ac7e8a1 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java @@ -4,15 +4,37 @@ import android.content.Context; import android.content.SharedPreferences; import android.preference.PreferenceManager; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.google.android.exoplayer2.SeekParameters; +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; +import com.google.android.exoplayer2.util.MimeTypes; import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.Subtitles; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.extractor.stream.SubtitlesFormat; +import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.playlist.PlayQueue; +import org.schabi.newpipe.playlist.PlayQueueItem; +import org.schabi.newpipe.playlist.SinglePlayQueue; import java.text.DecimalFormat; import java.text.NumberFormat; +import java.util.ArrayList; +import java.util.Collections; import java.util.Formatter; +import java.util.HashSet; +import java.util.List; import java.util.Locale; +import java.util.Set; -import javax.annotation.Nonnull; +import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FILL; +import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT; +import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM; public class PlayerHelper { private PlayerHelper() {} @@ -38,14 +60,92 @@ public class PlayerHelper { : stringFormatter.format("%02d:%02d", minutes, seconds).toString(); } - public static String formatSpeed(float speed) { + public static String formatSpeed(double speed) { return speedFormatter.format(speed); } - public static String formatPitch(float pitch) { + public static String formatPitch(double pitch) { return pitchFormatter.format(pitch); } + public static String mimeTypesOf(final SubtitlesFormat format) { + switch (format) { + case VTT: return MimeTypes.TEXT_VTT; + case TTML: return MimeTypes.APPLICATION_TTML; + default: throw new IllegalArgumentException("Unrecognized mime type: " + format.name()); + } + } + + @NonNull + public static String captionLanguageOf(@NonNull final Context context, + @NonNull final Subtitles subtitles) { + final String displayName = subtitles.getLocale().getDisplayName(subtitles.getLocale()); + return displayName + (subtitles.isAutoGenerated() ? " (" + context.getString(R.string.caption_auto_generated)+ ")" : ""); + } + + @NonNull + public static String resizeTypeOf(@NonNull final Context context, + @AspectRatioFrameLayout.ResizeMode final int resizeMode) { + switch (resizeMode) { + case RESIZE_MODE_FIT: return context.getResources().getString(R.string.resize_fit); + case RESIZE_MODE_FILL: return context.getResources().getString(R.string.resize_fill); + case RESIZE_MODE_ZOOM: return context.getResources().getString(R.string.resize_zoom); + default: throw new IllegalArgumentException("Unrecognized resize mode: " + resizeMode); + } + } + + @NonNull + public static String cacheKeyOf(@NonNull final StreamInfo info, @NonNull VideoStream video) { + return info.getUrl() + video.getResolution() + video.getFormat().getName(); + } + + @NonNull + public static String cacheKeyOf(@NonNull final StreamInfo info, @NonNull AudioStream audio) { + return info.getUrl() + audio.getAverageBitrate() + audio.getFormat().getName(); + } + + /** + * Given a {@link StreamInfo} and the existing queue items, provide the + * {@link SinglePlayQueue} consisting of the next video for auto queuing. + *

+ * This method detects and prevents cycle by naively checking if a + * candidate next video's url already exists in the existing items. + *

+ * To select the next video, {@link StreamInfo#getNextVideo()} is first + * checked. If it is nonnull and is not part of the existing items, then + * it will be used as the next video. Otherwise, an random item with + * non-repeating url will be selected from the {@link StreamInfo#getRelatedStreams()}. + * */ + @Nullable + public static PlayQueue autoQueueOf(@NonNull final StreamInfo info, + @NonNull final List existingItems) { + Set urls = new HashSet<>(existingItems.size()); + for (final PlayQueueItem item : existingItems) { + urls.add(item.getUrl()); + } + + final StreamInfoItem nextVideo = info.getNextVideo(); + if (nextVideo != null && !urls.contains(nextVideo.getUrl())) { + return new SinglePlayQueue(nextVideo); + } + + final List relatedItems = info.getRelatedStreams(); + if (relatedItems == null) return null; + + List autoQueueItems = new ArrayList<>(); + for (final InfoItem item : info.getRelatedStreams()) { + if (item instanceof StreamInfoItem && !urls.contains(item.getUrl())) { + autoQueueItems.add((StreamInfoItem) item); + } + } + Collections.shuffle(autoQueueItems); + return autoQueueItems.isEmpty() ? null : new SinglePlayQueue(autoQueueItems.get(0)); + } + + //////////////////////////////////////////////////////////////////////////// + // Settings Resolution + //////////////////////////////////////////////////////////////////////////// + public static boolean isResumeAfterAudioFocusGain(@NonNull final Context context) { return isResumeAfterAudioFocusGain(context, false); } @@ -58,10 +158,20 @@ public class PlayerHelper { return isUsingOldPlayer(context, false); } - public static boolean isRememberingPopupDimensions(@Nonnull final Context context) { + public static boolean isRememberingPopupDimensions(@NonNull final Context context) { return isRememberingPopupDimensions(context, true); } + public static boolean isAutoQueueEnabled(@NonNull final Context context) { + return isAutoQueueEnabled(context, false); + } + + @NonNull + public static SeekParameters getSeekParameters(@NonNull final Context context) { + return isUsingInexactSeek(context, false) ? + SeekParameters.CLOSEST_SYNC : SeekParameters.EXACT; + } + public static long getPreferredCacheSize(@NonNull final Context context) { return 64 * 1024 * 1024L; } @@ -70,31 +180,38 @@ public class PlayerHelper { return 512 * 1024L; } - public static int getMinBufferMs(@NonNull final Context context) { - return 15000; + /** + * Returns the number of milliseconds the player buffers for before starting playback. + * */ + public static int getPlaybackStartBufferMs(@NonNull final Context context) { + return 500; } - public static int getMaxBufferMs(@NonNull final Context context) { - return 30000; + /** + * Returns the minimum number of milliseconds the player always buffers to after starting + * playback. + * */ + public static int getPlaybackMinimumBufferMs(@NonNull final Context context) { + return 25000; } - public static long getBufferForPlaybackMs(@NonNull final Context context) { - return 2500L; - } - - public static long getBufferForPlaybackAfterRebufferMs(@NonNull final Context context) { - return 5000L; + /** + * Returns the maximum/optimal number of milliseconds the player will buffer to once the buffer + * hits the point of {@link #getPlaybackMinimumBufferMs(Context)}. + * */ + public static int getPlaybackOptimalBufferMs(@NonNull final Context context) { + return 60000; } public static boolean isUsingDSP(@NonNull final Context context) { return true; } - public static int getShutdownFlingVelocity(@Nonnull final Context context) { + public static int getShutdownFlingVelocity(@NonNull final Context context) { return 10000; } - public static int getTossFlingVelocity(@Nonnull final Context context) { + public static int getTossFlingVelocity(@NonNull final Context context) { return 2500; } @@ -119,7 +236,15 @@ public class PlayerHelper { return getPreferences(context).getBoolean(context.getString(R.string.use_old_player_key), b); } - private static boolean isRememberingPopupDimensions(@Nonnull final Context context, final boolean b) { + private static boolean isRememberingPopupDimensions(@NonNull final Context context, final boolean b) { return getPreferences(context).getBoolean(context.getString(R.string.popup_remember_size_pos_key), b); } + + private static boolean isUsingInexactSeek(@NonNull final Context context, final boolean b) { + return getPreferences(context).getBoolean(context.getString(R.string.use_inexact_seek_key), b); + } + + private static boolean isAutoQueueEnabled(@NonNull final Context context, final boolean b) { + return getPreferences(context).getBoolean(context.getString(R.string.auto_queue_key), b); + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/DummyPlaybackPreparer.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/DummyPlaybackPreparer.java new file mode 100644 index 000000000..431a90d8a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/DummyPlaybackPreparer.java @@ -0,0 +1,45 @@ +package org.schabi.newpipe.player.mediasession; + +import android.net.Uri; +import android.os.Bundle; +import android.os.ResultReceiver; + +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; + +public class DummyPlaybackPreparer implements MediaSessionConnector.PlaybackPreparer { + @Override + public long getSupportedPrepareActions() { + return 0; + } + + @Override + public void onPrepare() { + + } + + @Override + public void onPrepareFromMediaId(String mediaId, Bundle extras) { + + } + + @Override + public void onPrepareFromSearch(String query, Bundle extras) { + + } + + @Override + public void onPrepareFromUri(Uri uri, Bundle extras) { + + } + + @Override + public String[] getCommands() { + return new String[0]; + } + + @Override + public void onCommand(Player player, String command, Bundle extras, ResultReceiver cb) { + + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionCallback.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionCallback.java new file mode 100644 index 000000000..a1a57a87d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionCallback.java @@ -0,0 +1,17 @@ +package org.schabi.newpipe.player.mediasession; + +import android.support.v4.media.MediaDescriptionCompat; + +public interface MediaSessionCallback { + void onSkipToPrevious(); + void onSkipToNext(); + void onSkipToIndex(final int index); + + int getCurrentPlayingIndex(); + int getQueueSize(); + MediaDescriptionCompat getQueueMetadata(final int index); + + void onPlay(); + void onPause(); + void onSetShuffle(final boolean isShuffled); +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java new file mode 100644 index 000000000..429c26fd9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java @@ -0,0 +1,111 @@ +package org.schabi.newpipe.player.mediasession; + +import android.os.Bundle; +import android.os.ResultReceiver; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.media.session.MediaSessionCompat; + +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; +import com.google.android.exoplayer2.util.Util; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_NEXT; +import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS; +import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM; + + +public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator { + public static final int DEFAULT_MAX_QUEUE_SIZE = 10; + + private final MediaSessionCompat mediaSession; + private final MediaSessionCallback callback; + private final int maxQueueSize; + + private long activeQueueItemId; + + public PlayQueueNavigator(@NonNull final MediaSessionCompat mediaSession, + @NonNull final MediaSessionCallback callback) { + this.mediaSession = mediaSession; + this.callback = callback; + this.maxQueueSize = DEFAULT_MAX_QUEUE_SIZE; + + this.activeQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID; + } + + @Override + public long getSupportedQueueNavigatorActions(@Nullable Player player) { + return ACTION_SKIP_TO_NEXT | ACTION_SKIP_TO_PREVIOUS | ACTION_SKIP_TO_QUEUE_ITEM; + } + + @Override + public void onTimelineChanged(Player player) { + publishFloatingQueueWindow(); + } + + @Override + public void onCurrentWindowIndexChanged(Player player) { + if (activeQueueItemId == MediaSessionCompat.QueueItem.UNKNOWN_ID + || player.getCurrentTimeline().getWindowCount() > maxQueueSize) { + publishFloatingQueueWindow(); + } else if (!player.getCurrentTimeline().isEmpty()) { + activeQueueItemId = player.getCurrentWindowIndex(); + } + } + + @Override + public long getActiveQueueItemId(@Nullable Player player) { + return callback.getCurrentPlayingIndex(); + } + + @Override + public void onSkipToPrevious(Player player) { + callback.onSkipToPrevious(); + } + + @Override + public void onSkipToQueueItem(Player player, long id) { + callback.onSkipToIndex((int) id); + } + + @Override + public void onSkipToNext(Player player) { + callback.onSkipToNext(); + } + + private void publishFloatingQueueWindow() { + if (callback.getQueueSize() == 0) { + mediaSession.setQueue(Collections.emptyList()); + activeQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID; + return; + } + + // Yes this is almost a copypasta, got a problem with that? =\ + int windowCount = callback.getQueueSize(); + int currentWindowIndex = callback.getCurrentPlayingIndex(); + int queueSize = Math.min(maxQueueSize, windowCount); + int startIndex = Util.constrainValue(currentWindowIndex - ((queueSize - 1) / 2), 0, + windowCount - queueSize); + + List queue = new ArrayList<>(); + for (int i = startIndex; i < startIndex + queueSize; i++) { + queue.add(new MediaSessionCompat.QueueItem(callback.getQueueMetadata(i), i)); + } + mediaSession.setQueue(queue); + activeQueueItemId = currentWindowIndex; + } + + @Override + public String[] getCommands() { + return new String[0]; + } + + @Override + public void onCommand(Player player, String command, Bundle extras, ResultReceiver cb) { + + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueuePlaybackController.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueuePlaybackController.java new file mode 100644 index 000000000..2aa41bd63 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueuePlaybackController.java @@ -0,0 +1,31 @@ +package org.schabi.newpipe.player.mediasession; + +import android.support.v4.media.session.PlaybackStateCompat; + +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.ext.mediasession.DefaultPlaybackController; + +public class PlayQueuePlaybackController extends DefaultPlaybackController { + private final MediaSessionCallback callback; + + public PlayQueuePlaybackController(final MediaSessionCallback callback) { + super(); + this.callback = callback; + } + + @Override + public void onPlay(Player player) { + callback.onPlay(); + } + + @Override + public void onPause(Player player) { + callback.onPause(); + } + + @Override + public void onSetShuffleMode(Player player, int shuffleMode) { + callback.onSetShuffle(shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_ALL + || shuffleMode == PlaybackStateCompat.SHUFFLE_MODE_GROUP); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java new file mode 100644 index 000000000..5f029cc50 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java @@ -0,0 +1,84 @@ +package org.schabi.newpipe.player.mediasource; + +import android.support.annotation.NonNull; +import android.util.Log; + +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.upstream.Allocator; + +import org.schabi.newpipe.playlist.PlayQueueItem; + +import java.io.IOException; + +public class FailedMediaSource implements ManagedMediaSource { + private final String TAG = "FailedMediaSource@" + Integer.toHexString(hashCode()); + + private final PlayQueueItem playQueueItem; + private final Throwable error; + + private final long retryTimestamp; + + public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem, + @NonNull final Throwable error, + final long retryTimestamp) { + this.playQueueItem = playQueueItem; + this.error = error; + this.retryTimestamp = retryTimestamp; + } + + /** + * Permanently fail the play queue item associated with this source, with no hope of retrying. + * The error will always be propagated to ExoPlayer. + * */ + public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem, + @NonNull final Throwable error) { + this.playQueueItem = playQueueItem; + this.error = error; + this.retryTimestamp = Long.MAX_VALUE; + } + + public PlayQueueItem getStream() { + return playQueueItem; + } + + public Throwable getError() { + return error; + } + + private boolean canRetry() { + return System.currentTimeMillis() >= retryTimestamp; + } + + @Override + public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) { + Log.e(TAG, "Loading failed source: ", error); + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + throw new IOException(error); + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + return null; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) {} + + @Override + public void releaseSource() {} + + @Override + public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity, + final boolean isInterruptable) { + return newIdentity != playQueueItem || canRetry(); + } + + @Override + public boolean isStreamEqual(@NonNull PlayQueueItem stream) { + return playQueueItem == stream; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java new file mode 100644 index 000000000..fe7508ecc --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java @@ -0,0 +1,71 @@ +package org.schabi.newpipe.player.mediasource; + +import android.support.annotation.NonNull; + +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.upstream.Allocator; + +import org.schabi.newpipe.playlist.PlayQueueItem; + +import java.io.IOException; + +public class LoadedMediaSource implements ManagedMediaSource { + + private final MediaSource source; + private final PlayQueueItem stream; + private final long expireTimestamp; + + public LoadedMediaSource(@NonNull final MediaSource source, + @NonNull final PlayQueueItem stream, + final long expireTimestamp) { + this.source = source; + this.stream = stream; + this.expireTimestamp = expireTimestamp; + } + + public PlayQueueItem getStream() { + return stream; + } + + private boolean isExpired() { + return System.currentTimeMillis() >= expireTimestamp; + } + + @Override + public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) { + source.prepareSource(player, isTopLevelSource, listener); + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + source.maybeThrowSourceInfoRefreshError(); + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { + return source.createPeriod(id, allocator); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + source.releasePeriod(mediaPeriod); + } + + @Override + public void releaseSource() { + source.releaseSource(); + } + + @Override + public boolean shouldBeReplacedWith(@NonNull PlayQueueItem newIdentity, + final boolean isInterruptable) { + return newIdentity != stream || (isInterruptable && isExpired()); + } + + @Override + public boolean isStreamEqual(@NonNull PlayQueueItem stream) { + return this.stream == stream; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java new file mode 100644 index 000000000..46fd149bb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java @@ -0,0 +1,27 @@ +package org.schabi.newpipe.player.mediasource; + +import android.support.annotation.NonNull; + +import com.google.android.exoplayer2.source.MediaSource; + +import org.schabi.newpipe.playlist.PlayQueueItem; + +public interface ManagedMediaSource extends MediaSource { + /** + * Determines whether or not this {@link ManagedMediaSource} can be replaced. + * + * @param newIdentity a stream the {@link ManagedMediaSource} should encapsulate over, if + * it is different from the existing stream in the + * {@link ManagedMediaSource}, then it should be replaced. + * @param isInterruptable specifies if this {@link ManagedMediaSource} potentially + * being played. + * */ + boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity, + final boolean isInterruptable); + + /** + * Determines if the {@link PlayQueueItem} is the one the + * {@link ManagedMediaSource} encapsulates over. + * */ + boolean isStreamEqual(@NonNull final PlayQueueItem stream); +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java new file mode 100644 index 000000000..2c57f2f9c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java @@ -0,0 +1,31 @@ +package org.schabi.newpipe.player.mediasource; + +import android.support.annotation.NonNull; + +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.upstream.Allocator; + +import org.schabi.newpipe.playlist.PlayQueueItem; + +import java.io.IOException; + +public class PlaceholderMediaSource implements ManagedMediaSource { + // Do nothing, so this will stall the playback + @Override public void prepareSource(ExoPlayer player, boolean isTopLevelSource, Listener listener) {} + @Override public void maybeThrowSourceInfoRefreshError() throws IOException {} + @Override public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator) { return null; } + @Override public void releasePeriod(MediaPeriod mediaPeriod) {} + @Override public void releaseSource() {} + + @Override + public boolean shouldBeReplacedWith(@NonNull PlayQueueItem newIdentity, + final boolean isInterruptable) { + return true; + } + + @Override + public boolean isStreamEqual(@NonNull PlayQueueItem stream) { + return false; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/BasePlayerMediaSession.java b/app/src/main/java/org/schabi/newpipe/player/playback/BasePlayerMediaSession.java new file mode 100644 index 000000000..616879917 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playback/BasePlayerMediaSession.java @@ -0,0 +1,77 @@ +package org.schabi.newpipe.player.playback; + +import android.net.Uri; +import android.support.v4.media.MediaDescriptionCompat; + +import org.schabi.newpipe.player.BasePlayer; +import org.schabi.newpipe.player.mediasession.MediaSessionCallback; +import org.schabi.newpipe.playlist.PlayQueueItem; + +public class BasePlayerMediaSession implements MediaSessionCallback { + private BasePlayer player; + + public BasePlayerMediaSession(final BasePlayer player) { + this.player = player; + } + + @Override + public void onSkipToPrevious() { + player.onPlayPrevious(); + } + + @Override + public void onSkipToNext() { + player.onPlayNext(); + } + + @Override + public void onSkipToIndex(int index) { + if (player.getPlayQueue() == null) return; + player.onSelected(player.getPlayQueue().getItem(index)); + } + + @Override + public int getCurrentPlayingIndex() { + if (player.getPlayQueue() == null) return -1; + return player.getPlayQueue().getIndex(); + } + + @Override + public int getQueueSize() { + if (player.getPlayQueue() == null) return -1; + return player.getPlayQueue().size(); + } + + @Override + public MediaDescriptionCompat getQueueMetadata(int index) { + if (player.getPlayQueue() == null || player.getPlayQueue().getItem(index) == null) { + return null; + } + + final PlayQueueItem item = player.getPlayQueue().getItem(index); + MediaDescriptionCompat.Builder descriptionBuilder = new MediaDescriptionCompat.Builder() + .setMediaId(String.valueOf(index)) + .setTitle(item.getTitle()) + .setSubtitle(item.getUploader()); + + final Uri thumbnailUri = Uri.parse(item.getThumbnailUrl()); + if (thumbnailUri != null) descriptionBuilder.setIconUri(thumbnailUri); + + return descriptionBuilder.build(); + } + + @Override + public void onPlay() { + player.onPlay(); + } + + @Override + public void onPause() { + player.onPause(); + } + + @Override + public void onSetShuffle(boolean isShuffled) { + player.onShuffleModeEnabledChanged(isShuffled); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java b/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java new file mode 100644 index 000000000..d80ea5bae --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java @@ -0,0 +1,114 @@ +package org.schabi.newpipe.player.playback; + +import android.support.annotation.NonNull; +import android.text.TextUtils; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.FixedTrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; + +/** + * This class allows irregular text language labels for use when selecting text captions and + * is mostly a copy-paste from {@link DefaultTrackSelector}. + * + * This is a hack and should be removed once ExoPlayer fixes language normalization to accept + * a broader set of languages. + * */ +public class CustomTrackSelector extends DefaultTrackSelector { + private static final int WITHIN_RENDERER_CAPABILITIES_BONUS = 1000; + + private String preferredTextLanguage; + + public CustomTrackSelector(TrackSelection.Factory adaptiveTrackSelectionFactory) { + super(adaptiveTrackSelectionFactory); + } + + public String getPreferredTextLanguage() { + return preferredTextLanguage; + } + + public void setPreferredTextLanguage(@NonNull final String label) { + Assertions.checkNotNull(label); + if (!label.equals(preferredTextLanguage)) { + preferredTextLanguage = label; + invalidate(); + } + } + + /** @see DefaultTrackSelector#formatHasLanguage(Format, String)*/ + protected static boolean formatHasLanguage(Format format, String language) { + return language != null && TextUtils.equals(language, format.language); + } + + /** @see DefaultTrackSelector#formatHasNoLanguage(Format)*/ + protected static boolean formatHasNoLanguage(Format format) { + return TextUtils.isEmpty(format.language) || formatHasLanguage(format, C.LANGUAGE_UNDETERMINED); + } + + /** @see DefaultTrackSelector#selectTextTrack(TrackGroupArray, int[][], Parameters) */ + @Override + protected TrackSelection selectTextTrack(TrackGroupArray groups, int[][] formatSupport, + Parameters params) throws ExoPlaybackException { + TrackGroup selectedGroup = null; + int selectedTrackIndex = 0; + int selectedTrackScore = 0; + for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { + TrackGroup trackGroup = groups.get(groupIndex); + int[] trackFormatSupport = formatSupport[groupIndex]; + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + if (isSupported(trackFormatSupport[trackIndex], + params.exceedRendererCapabilitiesIfNecessary)) { + Format format = trackGroup.getFormat(trackIndex); + int maskedSelectionFlags = + format.selectionFlags & ~params.disabledTextTrackSelectionFlags; + boolean isDefault = (maskedSelectionFlags & C.SELECTION_FLAG_DEFAULT) != 0; + boolean isForced = (maskedSelectionFlags & C.SELECTION_FLAG_FORCED) != 0; + int trackScore; + boolean preferredLanguageFound = formatHasLanguage(format, preferredTextLanguage); + if (preferredLanguageFound + || (params.selectUndeterminedTextLanguage && formatHasNoLanguage(format))) { + if (isDefault) { + trackScore = 8; + } else if (!isForced) { + // Prefer non-forced to forced if a preferred text language has been specified. Where + // both are provided the non-forced track will usually contain the forced subtitles as + // a subset. + trackScore = 6; + } else { + trackScore = 4; + } + trackScore += preferredLanguageFound ? 1 : 0; + } else if (isDefault) { + trackScore = 3; + } else if (isForced) { + if (formatHasLanguage(format, params.preferredAudioLanguage)) { + trackScore = 2; + } else { + trackScore = 1; + } + } else { + // Track should not be selected. + continue; + } + if (isSupported(trackFormatSupport[trackIndex], false)) { + trackScore += WITHIN_RENDERER_CAPABILITIES_BONUS; + } + if (trackScore > selectedTrackScore) { + selectedGroup = trackGroup; + selectedTrackIndex = trackIndex; + selectedTrackScore = trackScore; + } + } + } + } + return selectedGroup == null ? null + : new FixedTrackSelection(selectedGroup, selectedTrackIndex); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/DeferredMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/playback/DeferredMediaSource.java deleted file mode 100644 index b0990f56a..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playback/DeferredMediaSource.java +++ /dev/null @@ -1,238 +0,0 @@ -package org.schabi.newpipe.player.playback; - -import android.support.annotation.NonNull; -import android.util.Log; - -import com.google.android.exoplayer2.ExoPlayer; -import com.google.android.exoplayer2.source.MediaPeriod; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.upstream.Allocator; - -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.playlist.PlayQueueItem; - -import java.io.IOException; - -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.Disposable; -import io.reactivex.functions.Consumer; -import io.reactivex.functions.Function; -import io.reactivex.schedulers.Schedulers; - -/** - * DeferredMediaSource is specifically designed to allow external control over when - * the source metadata are loaded while being compatible with ExoPlayer's playlists. - * - * This media source follows the structure of how NewPipeExtractor's - * {@link org.schabi.newpipe.extractor.stream.StreamInfoItem} is converted into - * {@link org.schabi.newpipe.extractor.stream.StreamInfo}. Once conversion is complete, - * this media source behaves identically as any other native media sources. - * */ -public final class DeferredMediaSource implements MediaSource { - private final String TAG = "DeferredMediaSource@" + Integer.toHexString(hashCode()); - - /** - * This state indicates the {@link DeferredMediaSource} has just been initialized or reset. - * The source must be prepared and loaded again before playback. - * */ - public final static int STATE_INIT = 0; - /** - * This state indicates the {@link DeferredMediaSource} has been prepared and is ready to load. - * */ - public final static int STATE_PREPARED = 1; - /** - * This state indicates the {@link DeferredMediaSource} has been loaded without errors and - * is ready for playback. - * */ - public final static int STATE_LOADED = 2; - - public interface Callback { - /** - * Player-specific {@link com.google.android.exoplayer2.source.MediaSource} resolution - * from a given StreamInfo. - * */ - MediaSource sourceOf(final PlayQueueItem item, final StreamInfo info); - } - - private PlayQueueItem stream; - private Callback callback; - private int state; - - private MediaSource mediaSource; - - /* Custom internal objects */ - private Disposable loader; - private ExoPlayer exoPlayer; - private Listener listener; - private Throwable error; - - public DeferredMediaSource(@NonNull final PlayQueueItem stream, - @NonNull final Callback callback) { - this.stream = stream; - this.callback = callback; - this.state = STATE_INIT; - } - - /** - * Returns the current state of the {@link DeferredMediaSource}. - * - * @see DeferredMediaSource#STATE_INIT - * @see DeferredMediaSource#STATE_PREPARED - * @see DeferredMediaSource#STATE_LOADED - * */ - public int state() { - return state; - } - - /** - * Parameters are kept in the class for delayed preparation. - * */ - @Override - public void prepareSource(ExoPlayer exoPlayer, boolean isTopLevelSource, Listener listener) { - this.exoPlayer = exoPlayer; - this.listener = listener; - this.state = STATE_PREPARED; - } - - /** - * Externally controlled loading. This method fully prepares the source to be used - * like any other native {@link com.google.android.exoplayer2.source.MediaSource}. - * - * Ideally, this should be called after this source has entered PREPARED state and - * called once only. - * - * If loading fails here, an error will be propagated out and result in an - * {@link com.google.android.exoplayer2.ExoPlaybackException ExoPlaybackException}, - * which is delegated to the player. - * */ - public synchronized void load() { - if (stream == null) { - Log.e(TAG, "Stream Info missing, media source loading terminated."); - return; - } - if (state != STATE_PREPARED || loader != null) return; - - Log.d(TAG, "Loading: [" + stream.getTitle() + "] with url: " + stream.getUrl()); - - final Function onReceive = new Function() { - @Override - public MediaSource apply(StreamInfo streamInfo) throws Exception { - return onStreamInfoReceived(stream, streamInfo); - } - }; - - final Consumer onSuccess = new Consumer() { - @Override - public void accept(MediaSource mediaSource) throws Exception { - onMediaSourceReceived(mediaSource); - } - }; - - final Consumer onError = new Consumer() { - @Override - public void accept(Throwable throwable) throws Exception { - onStreamInfoError(throwable); - } - }; - - loader = stream.getStream() - .observeOn(Schedulers.io()) - .map(onReceive) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(onSuccess, onError); - } - - private MediaSource onStreamInfoReceived(@NonNull final PlayQueueItem item, - @NonNull final StreamInfo info) throws Exception { - if (callback == null) { - throw new Exception("No available callback for resolving stream info."); - } - - final MediaSource mediaSource = callback.sourceOf(item, info); - - if (mediaSource == null) { - throw new Exception("Unable to resolve source from stream info. URL: " + stream.getUrl() + - ", audio count: " + info.audio_streams.size() + - ", video count: " + info.video_only_streams.size() + info.video_streams.size()); - } - - return mediaSource; - } - - private void onMediaSourceReceived(final MediaSource mediaSource) throws Exception { - if (exoPlayer == null || listener == null || mediaSource == null) { - throw new Exception("MediaSource loading failed. URL: " + stream.getUrl()); - } - - Log.d(TAG, " Loaded: [" + stream.getTitle() + "] with url: " + stream.getUrl()); - state = STATE_LOADED; - - this.mediaSource = mediaSource; - this.mediaSource.prepareSource(exoPlayer, false, listener); - } - - private void onStreamInfoError(final Throwable throwable) { - Log.e(TAG, "Loading error:", throwable); - error = throwable; - state = STATE_LOADED; - } - - /** - * Delegate all errors to the player after {@link #load() load} is complete. - * - * Specifically, this method is called after an exception has occurred during loading or - * {@link com.google.android.exoplayer2.source.MediaSource#prepareSource(ExoPlayer, boolean, Listener) prepareSource}. - * */ - @Override - public void maybeThrowSourceInfoRefreshError() throws IOException { - if (error != null) { - throw new IOException(error); - } - - if (mediaSource != null) { - mediaSource.maybeThrowSourceInfoRefreshError(); - } - } - - @Override - public MediaPeriod createPeriod(MediaPeriodId mediaPeriodId, Allocator allocator) { - return mediaSource.createPeriod(mediaPeriodId, allocator); - } - - /** - * Releases the media period (buffers). - * - * This may be called after {@link #releaseSource releaseSource}. - * */ - @Override - public void releasePeriod(MediaPeriod mediaPeriod) { - mediaSource.releasePeriod(mediaPeriod); - } - - /** - * Cleans up all internal custom objects creating during loading. - * - * This method is called when the parent {@link com.google.android.exoplayer2.source.MediaSource} - * is released or when the player is stopped. - * - * This method should not release or set null the resources passed in through the constructor. - * This method should not set null the internal {@link com.google.android.exoplayer2.source.MediaSource}. - * */ - @Override - public void releaseSource() { - if (mediaSource != null) { - mediaSource.releaseSource(); - } - if (loader != null) { - loader.dispose(); - } - - /* Do not set mediaSource as null here as it may be called through releasePeriod */ - loader = null; - exoPlayer = null; - listener = null; - error = null; - - state = STATE_INIT; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java index 04f1606fa..477358113 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java @@ -1,99 +1,157 @@ package org.schabi.newpipe.player.playback; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.util.Log; import com.google.android.exoplayer2.source.DynamicConcatenatingMediaSource; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ShuffleOrder; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.player.mediasource.FailedMediaSource; +import org.schabi.newpipe.player.mediasource.LoadedMediaSource; +import org.schabi.newpipe.player.mediasource.ManagedMediaSource; +import org.schabi.newpipe.player.mediasource.PlaceholderMediaSource; import org.schabi.newpipe.playlist.PlayQueue; import org.schabi.newpipe.playlist.PlayQueueItem; import org.schabi.newpipe.playlist.events.MoveEvent; import org.schabi.newpipe.playlist.events.PlayQueueEvent; import org.schabi.newpipe.playlist.events.RemoveEvent; +import org.schabi.newpipe.playlist.events.ReorderEvent; +import org.schabi.newpipe.util.ServiceHelper; -import java.util.ArrayList; -import java.util.List; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import io.reactivex.Observable; +import io.reactivex.Single; import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.annotations.NonNull; +import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.Disposable; import io.reactivex.disposables.SerialDisposable; import io.reactivex.functions.Consumer; +import io.reactivex.internal.subscriptions.EmptySubscription; import io.reactivex.subjects.PublishSubject; +import static org.schabi.newpipe.playlist.PlayQueue.DEBUG; + public class MediaSourceManager { - private final String TAG = "MediaSourceManager@" + Integer.toHexString(hashCode()); - // One-side rolling window size for default loading - // Effectively loads windowSize * 2 + 1 streams per call to load, must be greater than 0 - private final int windowSize; - private final PlaybackListener playbackListener; - private final PlayQueue playQueue; + @NonNull private final String TAG = "MediaSourceManager@" + hashCode(); - // Process only the last load order when receiving a stream of load orders (lessens I/O) - // The higher it is, the less loading occurs during rapid noncritical timeline changes - // Not recommended to go below 100ms + /** + * Determines how many streams before and after the current stream should be loaded. + * The default value (1) ensures seamless playback under typical network settings. + *

+ * The streams after the current will be loaded into the playlist timeline while the + * streams before will only be cached for future usage. + * + * @see #onMediaSourceReceived(PlayQueueItem, ManagedMediaSource) + * @see #update(int, MediaSource, Runnable) + * */ + private final static int WINDOW_SIZE = 1; + + @NonNull private final PlaybackListener playbackListener; + @NonNull private final PlayQueue playQueue; + + /** + * Determines the gap time between the playback position and the playback duration which + * the {@link #getEdgeIntervalSignal()} begins to request loading. + * + * @see #progressUpdateIntervalMillis + * */ + private final long playbackNearEndGapMillis; + /** + * Determines the interval which the {@link #getEdgeIntervalSignal()} waits for between + * each request for loading, once {@link #playbackNearEndGapMillis} has reached. + * */ + private final long progressUpdateIntervalMillis; + @NonNull private final Observable nearEndIntervalSignal; + + /** + * Process only the last load order when receiving a stream of load orders (lessens I/O). + *

+ * The higher it is, the less loading occurs during rapid noncritical timeline changes. + *

+ * Not recommended to go below 100ms. + * + * @see #loadDebounced() + * */ private final long loadDebounceMillis; - private final PublishSubject debouncedLoadSignal; - private final Disposable debouncedLoader; + @NonNull private final Disposable debouncedLoader; + @NonNull private final PublishSubject debouncedSignal; - private final DeferredMediaSource.Callback sourceBuilder; + @NonNull private Subscription playQueueReactor; - private DynamicConcatenatingMediaSource sources; + /** + * Determines the maximum number of disposables allowed in the {@link #loaderReactor}. + * Once exceeded, new calls to {@link #loadImmediate()} will evict all disposables in the + * {@link #loaderReactor} in order to load a new set of items. + * + * @see #loadImmediate() + * @see #maybeLoadItem(PlayQueueItem) + * */ + private final static int MAXIMUM_LOADER_SIZE = WINDOW_SIZE * 2 + 1; + @NonNull private final CompositeDisposable loaderReactor; + @NonNull private final Set loadingItems; + @NonNull private final SerialDisposable syncReactor; - private Subscription playQueueReactor; - private SerialDisposable syncReactor; + @NonNull private final AtomicBoolean isBlocked; - private boolean isBlocked; + @NonNull private DynamicConcatenatingMediaSource sources; public MediaSourceManager(@NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue) { - this(listener, playQueue, 1, 400L); + this(listener, playQueue, /*loadDebounceMillis=*/400L, + /*playbackNearEndGapMillis=*/TimeUnit.MILLISECONDS.convert(30, TimeUnit.SECONDS), + /*progressUpdateIntervalMillis*/TimeUnit.MILLISECONDS.convert(2, TimeUnit.SECONDS)); } private MediaSourceManager(@NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue, - final int windowSize, - final long loadDebounceMillis) { - if (windowSize <= 0) { - throw new UnsupportedOperationException("MediaSourceManager window size must be greater than 0"); + final long loadDebounceMillis, + final long playbackNearEndGapMillis, + final long progressUpdateIntervalMillis) { + if (playQueue.getBroadcastReceiver() == null) { + throw new IllegalArgumentException("Play Queue has not been initialized."); + } + if (playbackNearEndGapMillis < progressUpdateIntervalMillis) { + throw new IllegalArgumentException("Playback end gap=[" + playbackNearEndGapMillis + + " ms] must be longer than update interval=[ " + progressUpdateIntervalMillis + + " ms] for them to be useful."); } this.playbackListener = listener; this.playQueue = playQueue; - this.windowSize = windowSize; - this.loadDebounceMillis = loadDebounceMillis; - this.syncReactor = new SerialDisposable(); - this.debouncedLoadSignal = PublishSubject.create(); + this.playbackNearEndGapMillis = playbackNearEndGapMillis; + this.progressUpdateIntervalMillis = progressUpdateIntervalMillis; + this.nearEndIntervalSignal = getEdgeIntervalSignal(); + + this.loadDebounceMillis = loadDebounceMillis; + this.debouncedSignal = PublishSubject.create(); this.debouncedLoader = getDebouncedLoader(); - this.sourceBuilder = getSourceBuilder(); + this.playQueueReactor = EmptySubscription.INSTANCE; + this.loaderReactor = new CompositeDisposable(); + this.syncReactor = new SerialDisposable(); + + this.isBlocked = new AtomicBoolean(false); this.sources = new DynamicConcatenatingMediaSource(); + this.loadingItems = Collections.synchronizedSet(new HashSet<>()); + playQueue.getBroadcastReceiver() .observeOn(AndroidSchedulers.mainThread()) .subscribe(getReactor()); } - /*////////////////////////////////////////////////////////////////////////// - // DeferredMediaSource listener - //////////////////////////////////////////////////////////////////////////*/ - - private DeferredMediaSource.Callback getSourceBuilder() { - return new DeferredMediaSource.Callback() { - @Override - public MediaSource sourceOf(PlayQueueItem item, StreamInfo info) { - return playbackListener.sourceOf(item, info); - } - }; - } - /*////////////////////////////////////////////////////////////////////////// // Exposed Methods //////////////////////////////////////////////////////////////////////////*/ @@ -101,35 +159,17 @@ public class MediaSourceManager { * Dispose the manager and releases all message buses and loaders. * */ public void dispose() { - if (debouncedLoadSignal != null) debouncedLoadSignal.onComplete(); - if (debouncedLoader != null) debouncedLoader.dispose(); - if (playQueueReactor != null) playQueueReactor.cancel(); - if (syncReactor != null) syncReactor.dispose(); - if (sources != null) sources.releaseSource(); + if (DEBUG) Log.d(TAG, "dispose() called."); - playQueueReactor = null; - syncReactor = null; - sources = null; + debouncedSignal.onComplete(); + debouncedLoader.dispose(); + + playQueueReactor.cancel(); + loaderReactor.dispose(); + syncReactor.dispose(); + sources.releaseSource(); } - /** - * Loads the current playing stream and the streams within its windowSize bound. - * - * Unblocks the player once the item at the current index is loaded. - * */ - public void load() { - loadDebounced(); - } - - /** - * Blocks the player and repopulate the sources. - * - * Does not ensure the player is unblocked and should be done explicitly through {@link #load() load}. - * */ - public void reset() { - tryBlock(); - populateSources(); - } /*////////////////////////////////////////////////////////////////////////// // Event Reactor //////////////////////////////////////////////////////////////////////////*/ @@ -138,14 +178,14 @@ public class MediaSourceManager { return new Subscriber() { @Override public void onSubscribe(@NonNull Subscription d) { - if (playQueueReactor != null) playQueueReactor.cancel(); + playQueueReactor.cancel(); playQueueReactor = d; playQueueReactor.request(1); } @Override public void onNext(@NonNull PlayQueueEvent playQueueMessage) { - if (playQueueReactor != null) onPlayQueueChanged(playQueueMessage); + onPlayQueueChanged(playQueueMessage); } @Override @@ -158,20 +198,21 @@ public class MediaSourceManager { private void onPlayQueueChanged(final PlayQueueEvent event) { if (playQueue.isEmpty() && playQueue.isComplete()) { - playbackListener.shutdown(); + playbackListener.onPlaybackShutdown(); return; } // Event specific action switch (event.type()) { case INIT: - case REORDER: case ERROR: - reset(); - break; + maybeBlock(); case APPEND: populateSources(); break; + case SELECT: + maybeRenewCurrentIndex(); + break; case REMOVE: final RemoveEvent removeEvent = (RemoveEvent) event; remove(removeEvent.getRemoveIndex()); @@ -180,7 +221,12 @@ public class MediaSourceManager { final MoveEvent moveEvent = (MoveEvent) event; move(moveEvent.getFromIndex(), moveEvent.getToIndex()); break; - case SELECT: + case REORDER: + // Need to move to ensure the playing index from play queue matches that of + // the source timeline, and then window correction can take care of the rest + final ReorderEvent reorderEvent = (ReorderEvent) event; + move(reorderEvent.getFromSelectedIndex(), reorderEvent.getToSelectedIndex()); + break; case RECOVERY: default: break; @@ -191,11 +237,11 @@ public class MediaSourceManager { case INIT: case REORDER: case ERROR: + case SELECT: loadImmediate(); // low frequency, critical events break; case APPEND: case REMOVE: - case SELECT: case MOVE: case RECOVERY: default: @@ -204,156 +250,308 @@ public class MediaSourceManager { } if (!isPlayQueueReady()) { - tryBlock(); + maybeBlock(); playQueue.fetch(); } - if (playQueueReactor != null) playQueueReactor.request(1); + playQueueReactor.request(1); } /*////////////////////////////////////////////////////////////////////////// - // Internal Helpers + // Playback Locking //////////////////////////////////////////////////////////////////////////*/ private boolean isPlayQueueReady() { - return playQueue.isComplete() || playQueue.size() - playQueue.getIndex() > windowSize; + final boolean isWindowLoaded = playQueue.size() - playQueue.getIndex() > WINDOW_SIZE; + return playQueue.isComplete() || isWindowLoaded; } - private boolean tryBlock() { - if (!isBlocked) { - playbackListener.block(); - resetSources(); - isBlocked = true; - return true; + private boolean isPlaybackReady() { + if (sources.getSize() != playQueue.size()) return false; + + final ManagedMediaSource mediaSource = + (ManagedMediaSource) sources.getMediaSource(playQueue.getIndex()); + final PlayQueueItem playQueueItem = playQueue.getItem(); + return mediaSource.isStreamEqual(playQueueItem); + } + + private void maybeBlock() { + if (DEBUG) Log.d(TAG, "maybeBlock() called."); + + if (isBlocked.get()) return; + + playbackListener.onPlaybackBlock(); + resetSources(); + + isBlocked.set(true); + } + + private void maybeUnblock() { + if (DEBUG) Log.d(TAG, "maybeUnblock() called."); + + if (isPlayQueueReady() && isPlaybackReady() && isBlocked.get()) { + isBlocked.set(false); + playbackListener.onPlaybackUnblock(sources); } - return false; } - private boolean tryUnblock() { - if (isPlayQueueReady() && isBlocked && sources != null) { - isBlocked = false; - playbackListener.unblock(sources); - return true; - } - return false; - } + /*////////////////////////////////////////////////////////////////////////// + // Metadata Synchronization + //////////////////////////////////////////////////////////////////////////*/ + + private void maybeSync() { + if (DEBUG) Log.d(TAG, "onPlaybackSynchronize() called."); - private void sync() { final PlayQueueItem currentItem = playQueue.getItem(); - if (currentItem == null) return; + if (isBlocked.get() || !isPlaybackReady() || currentItem == null) return; - final Consumer syncPlayback = new Consumer() { - @Override - public void accept(StreamInfo streamInfo) throws Exception { - playbackListener.sync(currentItem, streamInfo); - } - }; + final Consumer onSuccess = info -> syncInternal(currentItem, info); + final Consumer onError = throwable -> syncInternal(currentItem, null); - final Consumer onError = new Consumer() { - @Override - public void accept(Throwable throwable) throws Exception { - Log.e(TAG, "Sync error:", throwable); - playbackListener.sync(currentItem,null); - } - }; + final Disposable sync = currentItem.getStream() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(onSuccess, onError); + syncReactor.set(sync); + } - syncReactor.set(currentItem.getStream().subscribe(syncPlayback, onError)); + private void syncInternal(@NonNull final PlayQueueItem item, + @Nullable final StreamInfo info) { + // Ensure the current item is up to date with the play queue + if (playQueue.getItem() == item) { + playbackListener.onPlaybackSynchronize(item, info); + } + } + + private void maybeSynchronizePlayer() { + maybeUnblock(); + maybeSync(); + } + + /*////////////////////////////////////////////////////////////////////////// + // MediaSource Loading + //////////////////////////////////////////////////////////////////////////*/ + + private Observable getEdgeIntervalSignal() { + return Observable.interval(progressUpdateIntervalMillis, TimeUnit.MILLISECONDS) + .filter(ignored -> playbackListener.isNearPlaybackEdge(playbackNearEndGapMillis)); + } + + private Disposable getDebouncedLoader() { + return debouncedSignal.mergeWith(nearEndIntervalSignal) + .debounce(loadDebounceMillis, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(timestamp -> loadImmediate()); } private void loadDebounced() { - debouncedLoadSignal.onNext(System.currentTimeMillis()); + debouncedSignal.onNext(System.currentTimeMillis()); } private void loadImmediate() { + if (DEBUG) Log.d(TAG, "MediaSource - loadImmediate() called"); // The current item has higher priority final int currentIndex = playQueue.getIndex(); final PlayQueueItem currentItem = playQueue.getItem(currentIndex); if (currentItem == null) return; - loadItem(currentItem); + + // Evict the items being loaded to free up memory + if (loaderReactor.size() > MAXIMUM_LOADER_SIZE) { + loaderReactor.clear(); + loadingItems.clear(); + } + maybeLoadItem(currentItem); // The rest are just for seamless playback - final int leftBound = Math.max(0, currentIndex - windowSize); - final int rightLimit = currentIndex + windowSize + 1; + // Although timeline is not updated prior to the current index, these sources are still + // loaded into the cache for faster retrieval at a potentially later time. + final int leftBound = Math.max(0, currentIndex - WINDOW_SIZE); + final int rightLimit = currentIndex + WINDOW_SIZE + 1; final int rightBound = Math.min(playQueue.size(), rightLimit); - final List items = new ArrayList<>(playQueue.getStreams().subList(leftBound, rightBound)); + final Set items = new HashSet<>( + playQueue.getStreams().subList(leftBound,rightBound)); // Do a round robin final int excess = rightLimit - playQueue.size(); - if (excess >= 0) items.addAll(playQueue.getStreams().subList(0, Math.min(playQueue.size(), excess))); + if (excess >= 0) { + items.addAll(playQueue.getStreams().subList(0, Math.min(playQueue.size(), excess))); + } + items.remove(currentItem); - for (final PlayQueueItem item: items) loadItem(item); - } - - private void loadItem(@Nullable final PlayQueueItem item) { - if (item == null) return; - - final int index = playQueue.indexOf(item); - if (index > sources.getSize() - 1) return; - - final DeferredMediaSource mediaSource = (DeferredMediaSource) sources.getMediaSource(playQueue.indexOf(item)); - if (mediaSource.state() == DeferredMediaSource.STATE_PREPARED) mediaSource.load(); - - tryUnblock(); - if (!isBlocked) sync(); - } - - private void resetSources() { - if (this.sources != null) this.sources.releaseSource(); - this.sources = new DynamicConcatenatingMediaSource(); - } - - private void populateSources() { - if (sources == null) return; - - for (final PlayQueueItem item : playQueue.getStreams()) { - insert(playQueue.indexOf(item), new DeferredMediaSource(item, sourceBuilder)); + for (final PlayQueueItem item : items) { + maybeLoadItem(item); } } - private Disposable getDebouncedLoader() { - return debouncedLoadSignal - .debounce(loadDebounceMillis, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Consumer() { - @Override - public void accept(Long timestamp) throws Exception { - loadImmediate(); + private void maybeLoadItem(@NonNull final PlayQueueItem item) { + if (DEBUG) Log.d(TAG, "maybeLoadItem() called."); + if (playQueue.indexOf(item) >= sources.getSize()) return; + + if (!loadingItems.contains(item) && isCorrectionNeeded(item)) { + if (DEBUG) Log.d(TAG, "MediaSource - Loading=[" + item.getTitle() + + "] with url=[" + item.getUrl() + "]"); + + loadingItems.add(item); + final Disposable loader = getLoadedMediaSource(item) + .observeOn(AndroidSchedulers.mainThread()) + /* No exception handling since getLoadedMediaSource guarantees nonnull return */ + .subscribe(mediaSource -> onMediaSourceReceived(item, mediaSource)); + loaderReactor.add(loader); + } + } + + private Single getLoadedMediaSource(@NonNull final PlayQueueItem stream) { + return stream.getStream().map(streamInfo -> { + final MediaSource source = playbackListener.sourceOf(stream, streamInfo); + if (source == null) { + final Exception exception = new IllegalStateException( + "Unable to resolve source from stream info." + + " URL: " + stream.getUrl() + + ", audio count: " + streamInfo.getAudioStreams().size() + + ", video count: " + streamInfo.getVideoOnlyStreams().size() + + streamInfo.getVideoStreams().size()); + return new FailedMediaSource(stream, exception); } - }); + + final long expiration = System.currentTimeMillis() + + ServiceHelper.getCacheExpirationMillis(streamInfo.getServiceId()); + return new LoadedMediaSource(source, stream, expiration); + }).onErrorReturn(throwable -> new FailedMediaSource(stream, throwable)); + } + + private void onMediaSourceReceived(@NonNull final PlayQueueItem item, + @NonNull final ManagedMediaSource mediaSource) { + if (DEBUG) Log.d(TAG, "MediaSource - Loaded=[" + item.getTitle() + + "] with url=[" + item.getUrl() + "]"); + + loadingItems.remove(item); + + final int itemIndex = playQueue.indexOf(item); + // Only update the playlist timeline for items at the current index or after. + if (itemIndex >= playQueue.getIndex() && isCorrectionNeeded(item)) { + if (DEBUG) Log.d(TAG, "MediaSource - Updating index=[" + itemIndex + "] with " + + "title=[" + item.getTitle() + "] at url=[" + item.getUrl() + "]"); + update(itemIndex, mediaSource, this::maybeSynchronizePlayer); + } + } + + /** + * Checks if the corresponding MediaSource in {@link DynamicConcatenatingMediaSource} + * for a given {@link PlayQueueItem} needs replacement, either due to gapless playback + * readiness or playlist desynchronization. + *

+ * If the given {@link PlayQueueItem} is currently being played and is already loaded, + * then correction is not only needed if the playlist is desynchronized. Otherwise, the + * check depends on the status (e.g. expiration or placeholder) of the + * {@link ManagedMediaSource}. + * */ + private boolean isCorrectionNeeded(@NonNull final PlayQueueItem item) { + final int index = playQueue.indexOf(item); + if (index == -1 || index >= sources.getSize()) return false; + + final ManagedMediaSource mediaSource = (ManagedMediaSource) sources.getMediaSource(index); + return mediaSource.shouldBeReplacedWith(item, + /*mightBeInProgress=*/index != playQueue.getIndex()); + } + + /** + * Checks if the current playing index contains an expired {@link ManagedMediaSource}. + * If so, the expired source is replaced by a {@link PlaceholderMediaSource} and + * {@link #loadImmediate()} is called to reload the current item. + *

+ * If not, then the media source at the current index is ready for playback, and + * {@link #maybeSynchronizePlayer()} is called. + *

+ * Under both cases, {@link #maybeSync()} will be called to ensure the listener + * is up-to-date. + * */ + private void maybeRenewCurrentIndex() { + final int currentIndex = playQueue.getIndex(); + if (sources.getSize() <= currentIndex) return; + + final ManagedMediaSource currentSource = + (ManagedMediaSource) sources.getMediaSource(currentIndex); + final PlayQueueItem currentItem = playQueue.getItem(); + if (!currentSource.shouldBeReplacedWith(currentItem, /*canInterruptOnRenew=*/true)) { + maybeSynchronizePlayer(); + return; + } + + if (DEBUG) Log.d(TAG, "MediaSource - Reloading currently playing, " + + "index=[" + currentIndex + "], item=[" + currentItem.getTitle() + "]"); + update(currentIndex, new PlaceholderMediaSource(), this::loadImmediate); } /*////////////////////////////////////////////////////////////////////////// - // Media Source List Manipulation + // MediaSource Playlist Helpers + //////////////////////////////////////////////////////////////////////////*/ + + private void resetSources() { + if (DEBUG) Log.d(TAG, "resetSources() called."); + + this.sources.releaseSource(); + this.sources = new DynamicConcatenatingMediaSource(false, + // Shuffling is done on PlayQueue, thus no need to use ExoPlayer's shuffle order + new ShuffleOrder.UnshuffledShuffleOrder(0)); + } + + private void populateSources() { + if (DEBUG) Log.d(TAG, "populateSources() called."); + if (sources.getSize() >= playQueue.size()) return; + + for (int index = sources.getSize() - 1; index < playQueue.size(); index++) { + emplace(index, new PlaceholderMediaSource()); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // MediaSource Playlist Manipulation //////////////////////////////////////////////////////////////////////////*/ /** - * Inserts a source into {@link DynamicConcatenatingMediaSource} with position - * in respect to the play queue. - * - * If the play queue index already exists, then the insert is ignored. + * Places a {@link MediaSource} into the {@link DynamicConcatenatingMediaSource} + * with position in respect to the play queue only if no {@link MediaSource} + * already exists at the given index. * */ - private void insert(final int queueIndex, final DeferredMediaSource source) { - if (sources == null) return; - if (queueIndex < 0 || queueIndex < sources.getSize()) return; + private synchronized void emplace(final int index, @NonNull final MediaSource source) { + if (index < sources.getSize()) return; - sources.addMediaSource(queueIndex, source); + sources.addMediaSource(index, source); } /** - * Removes a source from {@link DynamicConcatenatingMediaSource} with the given play queue index. - * - * If the play queue index does not exist, the removal is ignored. + * Removes a {@link MediaSource} from {@link DynamicConcatenatingMediaSource} + * at the given index. If this index is out of bound, then the removal is ignored. * */ - private void remove(final int queueIndex) { - if (sources == null) return; - if (queueIndex < 0 || queueIndex > sources.getSize()) return; + private synchronized void remove(final int index) { + if (index < 0 || index > sources.getSize()) return; - sources.removeMediaSource(queueIndex); + sources.removeMediaSource(index); } - private void move(final int source, final int target) { - if (sources == null) return; + /** + * Moves a {@link MediaSource} in {@link DynamicConcatenatingMediaSource} + * from the given source index to the target index. If either index is out of bound, + * then the call is ignored. + * */ + private synchronized void move(final int source, final int target) { if (source < 0 || target < 0) return; if (source >= sources.getSize() || target >= sources.getSize()) return; sources.moveMediaSource(source, target); } + + /** + * Updates the {@link MediaSource} in {@link DynamicConcatenatingMediaSource} + * at the given index with a given {@link MediaSource}. If the index is out of bound, + * then the replacement is ignored. + *

+ * Not recommended to use on indices LESS THAN the currently playing index, since + * this will modify the playback timeline prior to the index and may cause desynchronization + * on the playing item between {@link PlayQueue} and {@link DynamicConcatenatingMediaSource}. + * */ + private synchronized void update(final int index, @NonNull final MediaSource source, + @Nullable final Runnable finalizingAction) { + if (index < 0 || index >= sources.getSize()) return; + + sources.addMediaSource(index + 1, source, () -> + sources.removeMediaSource(index, finalizingAction)); + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java b/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java index dfed04c01..34c7702bc 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java @@ -11,6 +11,16 @@ import org.schabi.newpipe.playlist.PlayQueueItem; import java.util.List; public interface PlaybackListener { + + /** + * Called to check if the currently playing stream is close to the end of its playback. + * Implementation should return true when the current playback position is within + * timeToEndMillis or less until its playback completes or transitions. + * + * May be called at any time. + * */ + boolean isNearPlaybackEdge(final long timeToEndMillis); + /** * Called when the stream at the current queue index is not ready yet. * Signals to the listener to block the player from playing anything and notify the source @@ -18,7 +28,7 @@ public interface PlaybackListener { * * May be called at any time. * */ - void block(); + void onPlaybackBlock(); /** * Called when the stream at the current queue index is ready. @@ -26,16 +36,16 @@ public interface PlaybackListener { * * May be called only when the player is blocked. * */ - void unblock(final MediaSource mediaSource); + void onPlaybackUnblock(final MediaSource mediaSource); /** * Called when the queue index is refreshed. * Signals to the listener to synchronize the player's window to the manager's * window. * - * May be called only after unblock is called. + * May be called anytime at any amount once unblock is called. * */ - void sync(@NonNull final PlayQueueItem item, @Nullable final StreamInfo info); + void onPlaybackSynchronize(@NonNull final PlayQueueItem item, @Nullable final StreamInfo info); /** * Requests the listener to resolve a stream info into a media source @@ -53,5 +63,5 @@ public interface PlaybackListener { * * May be called at any time. * */ - void shutdown(); + void onPlaybackShutdown(); } diff --git a/app/src/main/java/org/schabi/newpipe/playlist/AbstractInfoPlayQueue.java b/app/src/main/java/org/schabi/newpipe/playlist/AbstractInfoPlayQueue.java index 58ec3c997..2b31cd340 100644 --- a/app/src/main/java/org/schabi/newpipe/playlist/AbstractInfoPlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/playlist/AbstractInfoPlayQueue.java @@ -26,13 +26,13 @@ abstract class AbstractInfoPlayQueue ext transient Disposable fetchReactor; AbstractInfoPlayQueue(final U item) { - this(item.getServiceId(), item.getUrl(), null, Collections.emptyList(), 0); + this(item.getServiceId(), item.getUrl(), null, Collections.emptyList(), 0); } AbstractInfoPlayQueue(final int serviceId, final String url, final String nextPageUrl, - final List streams, + final List streams, final int index) { super(index, extractListItems(streams)); @@ -65,10 +65,10 @@ abstract class AbstractInfoPlayQueue ext @Override public void onSuccess(@NonNull T result) { isInitial = false; - if (!result.has_more_streams) isComplete = true; - nextUrl = result.next_streams_url; + if (!result.hasNextPage()) isComplete = true; + nextUrl = result.getNextPageUrl(); - append(extractListItems(result.related_streams)); + append(extractListItems(result.getRelatedItems())); fetchReactor.dispose(); fetchReactor = null; @@ -83,8 +83,8 @@ abstract class AbstractInfoPlayQueue ext }; } - SingleObserver getNextItemsObserver() { - return new SingleObserver() { + SingleObserver getNextPageObserver() { + return new SingleObserver() { @Override public void onSubscribe(@NonNull Disposable d) { if (isComplete || isInitial || (fetchReactor != null && !fetchReactor.isDisposed())) { @@ -95,11 +95,11 @@ abstract class AbstractInfoPlayQueue ext } @Override - public void onSuccess(@NonNull ListExtractor.NextItemsResult result) { - if (!result.hasMoreStreams()) isComplete = true; - nextUrl = result.nextItemsUrl; + public void onSuccess(@NonNull ListExtractor.InfoItemsPage result) { + if (!result.hasNextPage()) isComplete = true; + nextUrl = result.getNextPageUrl(); - append(extractListItems(result.nextItemsList)); + append(extractListItems(result.getItems())); fetchReactor.dispose(); fetchReactor = null; @@ -118,9 +118,10 @@ abstract class AbstractInfoPlayQueue ext public void dispose() { super.dispose(); if (fetchReactor != null) fetchReactor.dispose(); + fetchReactor = null; } - private static List extractListItems(final List infos) { + private static List extractListItems(final List infos) { List result = new ArrayList<>(); for (final InfoItem stream : infos) { if (stream instanceof StreamInfoItem) { diff --git a/app/src/main/java/org/schabi/newpipe/playlist/ChannelPlayQueue.java b/app/src/main/java/org/schabi/newpipe/playlist/ChannelPlayQueue.java index d3e31982a..d37b84072 100644 --- a/app/src/main/java/org/schabi/newpipe/playlist/ChannelPlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/playlist/ChannelPlayQueue.java @@ -3,6 +3,7 @@ package org.schabi.newpipe.playlist; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.ChannelInfoItem; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.util.ExtractorHelper; import java.util.List; @@ -16,13 +17,13 @@ public final class ChannelPlayQueue extends AbstractInfoPlayQueue streams, + final List streams, final int index) { super(serviceId, url, nextPageUrl, streams, index); } @@ -43,7 +44,7 @@ public final class ChannelPlayQueue extends AbstractInfoPlayQueue backup; private ArrayList streams; - private final AtomicInteger queueIndex; + @NonNull private final AtomicInteger queueIndex; private transient BehaviorSubject eventBroadcast; private transient Flowable broadcastReceiver; @@ -83,6 +84,7 @@ public abstract class PlayQueue implements Serializable { if (eventBroadcast != null) eventBroadcast.onComplete(); if (reportingReactor != null) reportingReactor.cancel(); + eventBroadcast = null; broadcastReceiver = null; reportingReactor = null; } @@ -131,7 +133,7 @@ public abstract class PlayQueue implements Serializable { * Returns the index of the given item using referential equality. * May be null despite play queue contains identical item. * */ - public int indexOf(final PlayQueueItem item) { + public int indexOf(@NonNull final PlayQueueItem item) { // referential equality, can't think of a better way to do this // todo: better than this return streams.indexOf(item); @@ -170,7 +172,7 @@ public abstract class PlayQueue implements Serializable { * Returns the play queue's update broadcast. * May be null if the play queue message bus is not initialized. * */ - @NonNull + @Nullable public Flowable getBroadcastReceiver() { return broadcastReceiver; } @@ -211,7 +213,7 @@ public abstract class PlayQueue implements Serializable { * * @see #append(List items) * */ - public synchronized void append(final PlayQueueItem... items) { + public synchronized void append(@NonNull final PlayQueueItem... items) { append(Arrays.asList(items)); } @@ -223,7 +225,7 @@ public abstract class PlayQueue implements Serializable { * * Will emit a {@link AppendEvent} on any given context. * */ - public synchronized void append(final List items) { + public synchronized void append(@NonNull final List items) { List itemList = new ArrayList<>(items); if (isShuffled()) { @@ -349,6 +351,7 @@ public abstract class PlayQueue implements Serializable { if (backup == null) { backup = new ArrayList<>(streams); } + final int originIndex = getIndex(); final PlayQueueItem current = getItem(); Collections.shuffle(streams); @@ -358,7 +361,7 @@ public abstract class PlayQueue implements Serializable { } queueIndex.set(0); - broadcast(new ReorderEvent()); + broadcast(new ReorderEvent(originIndex, queueIndex.get())); } /** @@ -371,6 +374,7 @@ public abstract class PlayQueue implements Serializable { * */ public synchronized void unshuffle() { if (backup == null) return; + final int originIndex = getIndex(); final PlayQueueItem current = getItem(); streams.clear(); @@ -384,14 +388,14 @@ public abstract class PlayQueue implements Serializable { queueIndex.set(0); } - broadcast(new ReorderEvent()); + broadcast(new ReorderEvent(originIndex, queueIndex.get())); } /*////////////////////////////////////////////////////////////////////////// // Rx Broadcast //////////////////////////////////////////////////////////////////////////*/ - private void broadcast(final PlayQueueEvent event) { + private void broadcast(@NonNull final PlayQueueEvent event) { if (eventBroadcast != null) { eventBroadcast.onNext(event); } diff --git a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java index e16693ec6..dd320c2bc 100644 --- a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueAdapter.java @@ -63,18 +63,18 @@ public class PlayQueueAdapter extends RecyclerView.Adapter observer = new Observer() { + private Observer getReactor() { + return new Observer() { @Override public void onSubscribe(@NonNull Disposable d) { if (playQueueReactor != null) playQueueReactor.dispose(); @@ -95,7 +95,6 @@ public class PlayQueueAdapter extends RecyclerView.Adapter stream; PlayQueueItem(@NonNull final StreamInfo info) { - this(info.getName(), info.getUrl(), info.getServiceId(), info.duration, info.thumbnail_url, info.uploader_name); + this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(), + info.getThumbnailUrl(), info.getUploaderName(), info.getStreamType()); this.stream = Single.just(info); } PlayQueueItem(@NonNull final StreamInfoItem item) { - this(item.getName(), item.getUrl(), item.getServiceId(), item.duration, item.thumbnail_url, item.uploader_name); + this(item.getName(), item.getUrl(), item.getServiceId(), item.getDuration(), + item.getThumbnailUrl(), item.getUploaderName(), item.getStreamType()); } - private PlayQueueItem(final String name, final String url, final int serviceId, - final long duration, final String thumbnailUrl, final String uploader) { - this.title = name; - this.url = url; + private PlayQueueItem(@Nullable final String name, @Nullable final String url, + final int serviceId, final long duration, + @Nullable final String thumbnailUrl, @Nullable final String uploader, + @NonNull final StreamType streamType) { + this.title = name != null ? name : EMPTY_STRING; + this.url = url != null ? url : EMPTY_STRING; this.serviceId = serviceId; this.duration = duration; - this.thumbnailUrl = thumbnailUrl; - this.uploader = uploader; + this.thumbnailUrl = thumbnailUrl != null ? thumbnailUrl : EMPTY_STRING; + this.uploader = uploader != null ? uploader : EMPTY_STRING; + this.streamType = streamType; this.recoveryPosition = RECOVERY_UNSET; } @@ -78,6 +84,11 @@ public class PlayQueueItem implements Serializable { return uploader; } + @NonNull + public StreamType getStreamType() { + return streamType; + } + public long getRecoveryPosition() { return recoveryPosition; } @@ -94,17 +105,9 @@ public class PlayQueueItem implements Serializable { @NonNull private Single getInfo() { - final Consumer onError = new Consumer() { - @Override - public void accept(Throwable throwable) throws Exception { - error = throwable; - } - }; - return ExtractorHelper.getStreamInfo(this.serviceId, this.url, false) .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError(onError); + .doOnError(throwable -> error = throwable); } //////////////////////////////////////////////////////////////////////////// diff --git a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItemBuilder.java b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItemBuilder.java index 82277a4e7..7042bea89 100644 --- a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItemBuilder.java +++ b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItemBuilder.java @@ -1,28 +1,22 @@ package org.schabi.newpipe.playlist; import android.content.Context; -import android.graphics.Bitmap; import android.text.TextUtils; import android.view.MotionEvent; import android.view.View; import com.nostra13.universalimageloader.core.DisplayImageOptions; import com.nostra13.universalimageloader.core.ImageLoader; -import com.nostra13.universalimageloader.core.assist.ImageScaleType; -import com.nostra13.universalimageloader.core.process.BitmapProcessor; import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.Localization; - public class PlayQueueItemBuilder { private static final String TAG = PlayQueueItemBuilder.class.toString(); - private final int thumbnailWidthPx; - private final int thumbnailHeightPx; - private final DisplayImageOptions imageOptions; - public interface OnSelectedListener { void selected(PlayQueueItem item, View view); void held(PlayQueueItem item, View view); @@ -31,11 +25,7 @@ public class PlayQueueItemBuilder { private OnSelectedListener onItemClickListener; - public PlayQueueItemBuilder(final Context context) { - thumbnailWidthPx = context.getResources().getDimensionPixelSize(R.dimen.play_queue_thumbnail_width); - thumbnailHeightPx = context.getResources().getDimensionPixelSize(R.dimen.play_queue_thumbnail_height); - imageOptions = buildImageOptions(thumbnailWidthPx, thumbnailHeightPx); - } + public PlayQueueItemBuilder(final Context context) {} public void setOnSelectedListener(OnSelectedListener listener) { this.onItemClickListener = listener; @@ -43,7 +33,8 @@ public class PlayQueueItemBuilder { public void buildStreamInfoItem(final PlayQueueItemHolder holder, final PlayQueueItem item) { if (!TextUtils.isEmpty(item.getTitle())) holder.itemVideoTitleView.setText(item.getTitle()); - if (!TextUtils.isEmpty(item.getUploader())) holder.itemAdditionalDetailsView.setText(item.getUploader()); + holder.itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.getUploader(), + NewPipe.getNameOfService(item.getServiceId()))); if (item.getDuration() > 0) { holder.itemDurationView.setText(Localization.getDurationString(item.getDuration())); @@ -51,26 +42,21 @@ public class PlayQueueItemBuilder { holder.itemDurationView.setVisibility(View.GONE); } - ImageLoader.getInstance().displayImage(item.getThumbnailUrl(), holder.itemThumbnailView, imageOptions); + ImageLoader.getInstance().displayImage(item.getThumbnailUrl(), holder.itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS); - holder.itemRoot.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - if (onItemClickListener != null) { - onItemClickListener.selected(item, view); - } + holder.itemRoot.setOnClickListener(view -> { + if (onItemClickListener != null) { + onItemClickListener.selected(item, view); } }); - holder.itemRoot.setOnLongClickListener(new View.OnLongClickListener() { - @Override - public boolean onLongClick(View view) { - if (onItemClickListener != null) { - onItemClickListener.held(item, view); - return true; - } - return false; + holder.itemRoot.setOnLongClickListener(view -> { + if (onItemClickListener != null) { + onItemClickListener.held(item, view); + return true; } + return false; }); holder.itemThumbnailView.setOnTouchListener(getOnTouchListener(holder)); @@ -78,37 +64,13 @@ public class PlayQueueItemBuilder { } private View.OnTouchListener getOnTouchListener(final PlayQueueItemHolder holder) { - return new View.OnTouchListener() { - @Override - public boolean onTouch(View view, MotionEvent motionEvent) { - view.performClick(); - if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { - onItemClickListener.onStartDrag(holder); - } - return false; + return (view, motionEvent) -> { + view.performClick(); + if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN + && onItemClickListener != null) { + onItemClickListener.onStartDrag(holder); } + return false; }; } - - private DisplayImageOptions buildImageOptions(final int widthPx, final int heightPx) { - final BitmapProcessor bitmapProcessor = new BitmapProcessor() { - @Override - public Bitmap process(Bitmap bitmap) { - final Bitmap resizedBitmap = Bitmap.createScaledBitmap(bitmap, widthPx, heightPx, false); - bitmap.recycle(); - return resizedBitmap; - } - }; - - return new DisplayImageOptions.Builder() - .showImageOnFail(R.drawable.dummy_thumbnail) - .showImageForEmptyUri(R.drawable.dummy_thumbnail) - .showImageOnLoading(R.drawable.dummy_thumbnail) - .bitmapConfig(Bitmap.Config.RGB_565) // Users won't be able to see much anyways - .preProcessor(bitmapProcessor) - .imageScaleType(ImageScaleType.EXACTLY) - .cacheInMemory(true) - .cacheOnDisk(true) - .build(); - } } diff --git a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItemTouchCallback.java b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItemTouchCallback.java new file mode 100644 index 000000000..405dba11e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItemTouchCallback.java @@ -0,0 +1,52 @@ +package org.schabi.newpipe.playlist; + +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.helper.ItemTouchHelper; + +public abstract class PlayQueueItemTouchCallback extends ItemTouchHelper.SimpleCallback { + private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 10; + private static final int MAXIMUM_INITIAL_DRAG_VELOCITY = 25; + + public PlayQueueItemTouchCallback() { + super(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0); + } + + public abstract void onMove(final int sourceIndex, final int targetIndex); + + @Override + public int interpolateOutOfBoundsScroll(RecyclerView recyclerView, int viewSize, + int viewSizeOutOfBounds, int totalSize, + long msSinceStartScroll) { + final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, + viewSizeOutOfBounds, totalSize, msSinceStartScroll); + final int clampedAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY, + Math.min(Math.abs(standardSpeed), MAXIMUM_INITIAL_DRAG_VELOCITY)); + return clampedAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); + } + + @Override + public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder source, + RecyclerView.ViewHolder target) { + if (source.getItemViewType() != target.getItemViewType()) { + return false; + } + + final int sourceIndex = source.getLayoutPosition(); + final int targetIndex = target.getLayoutPosition(); + onMove(sourceIndex, targetIndex); + return true; + } + + @Override + public boolean isLongPressDragEnabled() { + return false; + } + + @Override + public boolean isItemViewSwipeEnabled() { + return false; + } + + @Override + public void onSwiped(RecyclerView.ViewHolder viewHolder, int swipeDir) {} +} diff --git a/app/src/main/java/org/schabi/newpipe/playlist/PlaylistPlayQueue.java b/app/src/main/java/org/schabi/newpipe/playlist/PlaylistPlayQueue.java index bb20ca332..d9e1d2d2b 100644 --- a/app/src/main/java/org/schabi/newpipe/playlist/PlaylistPlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/playlist/PlaylistPlayQueue.java @@ -3,6 +3,7 @@ package org.schabi.newpipe.playlist; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.util.ExtractorHelper; import java.util.List; @@ -16,13 +17,13 @@ public final class PlaylistPlayQueue extends AbstractInfoPlayQueue streams, + final List streams, final int index) { super(serviceId, url, nextPageUrl, streams, index); } @@ -43,7 +44,7 @@ public final class PlaylistPlayQueue extends AbstractInfoPlayQueue items, final int index) { + super(index, playQueueItemsOf(items)); + } + + private static List playQueueItemsOf(List items) { + List playQueueItems = new ArrayList<>(items.size()); + for (final StreamInfoItem item : items) { + playQueueItems.add(new PlayQueueItem(item)); + } + return playQueueItems; } @Override diff --git a/app/src/main/java/org/schabi/newpipe/playlist/events/ReorderEvent.java b/app/src/main/java/org/schabi/newpipe/playlist/events/ReorderEvent.java index f1d09d457..19bb632d8 100644 --- a/app/src/main/java/org/schabi/newpipe/playlist/events/ReorderEvent.java +++ b/app/src/main/java/org/schabi/newpipe/playlist/events/ReorderEvent.java @@ -1,12 +1,24 @@ package org.schabi.newpipe.playlist.events; public class ReorderEvent implements PlayQueueEvent { + private final int fromSelectedIndex; + private final int toSelectedIndex; + @Override public PlayQueueEventType type() { return PlayQueueEventType.REORDER; } - public ReorderEvent() { + public ReorderEvent(final int fromSelectedIndex, final int toSelectedIndex) { + this.fromSelectedIndex = fromSelectedIndex; + this.toSelectedIndex = toSelectedIndex; + } + public int getFromSelectedIndex() { + return fromSelectedIndex; + } + + public int getToSelectedIndex() { + return toSelectedIndex; } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java index 4161f96c1..f0ab3bc03 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java @@ -5,11 +5,16 @@ import android.app.AlertDialog; import android.content.DialogInterface; import android.content.Intent; import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.v7.preference.ListPreference; import android.support.v7.preference.Preference; import android.util.Log; import android.widget.Toast; +import com.nononsenseapps.filepicker.Utils; +import com.nostra13.universalimageloader.core.ImageLoader; + import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; @@ -34,8 +39,6 @@ import java.util.zip.ZipFile; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; -import javax.annotation.Nonnull; - public class ContentSettingsFragment extends BasePreferenceFragment { private static final int REQUEST_IMPORT_PATH = 8945; @@ -46,6 +49,29 @@ public class ContentSettingsFragment extends BasePreferenceFragment { private File newpipe_db; private File newpipe_db_journal; + private String thumbnailLoadToggleKey; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + thumbnailLoadToggleKey = getString(R.string.download_thumbnail_key); + } + + @Override + public boolean onPreferenceTreeClick(Preference preference) { + if (preference.getKey().equals(thumbnailLoadToggleKey)) { + final ImageLoader imageLoader = ImageLoader.getInstance(); + imageLoader.stop(); + imageLoader.clearDiskCache(); + imageLoader.clearMemoryCache(); + imageLoader.resume(); + Toast.makeText(preference.getContext(), R.string.thumbnail_cache_wipe_complete_notice, + Toast.LENGTH_SHORT).show(); + } + + return super.onPreferenceTreeClick(preference); + } + @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { @@ -73,7 +99,7 @@ public class ContentSettingsFragment extends BasePreferenceFragment { .putString(getString(R.string.main_page_selectd_kiosk_id), kioskId).apply(); String serviceName = ""; try { - serviceName = NewPipe.getService(service_id).getServiceInfo().name; + serviceName = NewPipe.getService(service_id).getServiceInfo().getName(); } catch (ExtractionException e) { onError(e); } @@ -140,15 +166,15 @@ public class ContentSettingsFragment extends BasePreferenceFragment { } @Override - public void onActivityResult(int requestCode, int resultCode, @Nonnull Intent data) { + public void onActivityResult(int requestCode, int resultCode, @NonNull Intent data) { super.onActivityResult(requestCode, resultCode, data); if (DEBUG) { Log.d(TAG, "onActivityResult() called with: requestCode = [" + requestCode + "], resultCode = [" + resultCode + "], data = [" + data + "]"); } if ((requestCode == REQUEST_IMPORT_PATH || requestCode == REQUEST_EXPORT_PATH) - && resultCode == Activity.RESULT_OK) { - String path = data.getData().getPath(); + && resultCode == Activity.RESULT_OK && data.getData() != null) { + String path = Utils.getFileForUri(data.getData()).getAbsolutePath(); if (requestCode == REQUEST_EXPORT_PATH) { SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US); exportDatabase(path + "/NewPipeData-" + sdf.format(new Date()) + ".zip"); @@ -245,7 +271,7 @@ public class ContentSettingsFragment extends BasePreferenceFragment { String summary = String.format(getString(R.string.service_kiosk_string), - service.getServiceInfo().name, + service.getServiceInfo().getName(), kioskName); mainPagePref.setSummary(summary); diff --git a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java new file mode 100644 index 000000000..0956f47d6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java @@ -0,0 +1,12 @@ +package org.schabi.newpipe.settings; + +import android.os.Bundle; + +import org.schabi.newpipe.R; + +public class DebugSettingsFragment extends BasePreferenceFragment { + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + addPreferencesFromResource(R.xml.debug_settings); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java index 9a065d9d8..8214d7b4b 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java @@ -7,6 +7,8 @@ import android.support.annotation.Nullable; import android.support.v7.preference.Preference; import android.util.Log; +import com.nononsenseapps.filepicker.Utils; + import org.schabi.newpipe.R; import org.schabi.newpipe.util.FilePickerActivityHelper; @@ -69,9 +71,11 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { Log.d(TAG, "onActivityResult() called with: requestCode = [" + requestCode + "], resultCode = [" + resultCode + "], data = [" + data + "]"); } - if ((requestCode == REQUEST_DOWNLOAD_PATH || requestCode == REQUEST_DOWNLOAD_AUDIO_PATH) && resultCode == Activity.RESULT_OK) { + if ((requestCode == REQUEST_DOWNLOAD_PATH || requestCode == REQUEST_DOWNLOAD_AUDIO_PATH) + && resultCode == Activity.RESULT_OK && data.getData() != null) { String key = getString(requestCode == REQUEST_DOWNLOAD_PATH ? R.string.download_path_key : R.string.download_path_audio_key); - String path = data.getData().getPath(); + String path = Utils.getFileForUri(data.getData()).getAbsolutePath(); + defaultPreferences.edit().putString(key, path).apply(); updatePreferencesSummary(); } diff --git a/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java index e0836e06c..53e8d6fc4 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java @@ -1,12 +1,35 @@ package org.schabi.newpipe.settings; import android.os.Bundle; +import android.support.annotation.Nullable; +import android.support.v7.preference.Preference; +import android.widget.Toast; import org.schabi.newpipe.R; +import org.schabi.newpipe.util.InfoCache; public class HistorySettingsFragment extends BasePreferenceFragment { + private String cacheWipeKey; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + cacheWipeKey = getString(R.string.metadata_cache_wipe_key); + } + @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { addPreferencesFromResource(R.xml.history_settings); } + + @Override + public boolean onPreferenceTreeClick(Preference preference) { + if (preference.getKey().equals(cacheWipeKey)) { + InfoCache.getInstance().clearCache(); + Toast.makeText(preference.getContext(), R.string.metadata_cache_wipe_complete_notice, + Toast.LENGTH_SHORT).show(); + } + + return super.onPreferenceTreeClick(preference); + } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java index 230f3b5ee..5e07e2b12 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java @@ -1,12 +1,21 @@ package org.schabi.newpipe.settings; import android.os.Bundle; +import android.support.v7.preference.Preference; +import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.R; public class MainSettingsFragment extends BasePreferenceFragment { + public static final boolean DEBUG = !BuildConfig.BUILD_TYPE.equals("release"); + @Override public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { addPreferencesFromResource(R.xml.main_settings); + + if (!DEBUG) { + final Preference debug = findPreference(getString(R.string.debug_pref_screen_key)); + getPreferenceScreen().removePreference(debug); + } } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java index 109466c02..92f98a9a2 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java +++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java @@ -64,6 +64,7 @@ public class NewPipeSettings { PreferenceManager.setDefaultValues(context, R.xml.history_settings, true); PreferenceManager.setDefaultValues(context, R.xml.main_settings, true); PreferenceManager.setDefaultValues(context, R.xml.video_audio_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.debug_settings, true); getVideoDownloadFolder(context); getAudioDownloadFolder(context); diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java index 97af11f1b..c0eadfaa8 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java @@ -3,10 +3,10 @@ package org.schabi.newpipe.settings; import android.app.Activity; import android.content.DialogInterface; import android.os.Bundle; +import android.support.annotation.NonNull; import android.support.v4.app.DialogFragment; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; -import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -18,9 +18,9 @@ import com.nostra13.universalimageloader.core.ImageLoader; import org.schabi.newpipe.R; import org.schabi.newpipe.database.subscription.SubscriptionEntity; -import org.schabi.newpipe.fragments.subscription.SubscriptionService; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.report.UserAction; +import org.schabi.newpipe.subscription.SubscriptionService; import java.util.List; import java.util.Vector; @@ -87,7 +87,7 @@ public class SelectChannelFragment extends DialogFragment { @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View v = inflater.inflate(R.layout.select_channel_fragment, container, false); recyclerView = (RecyclerView) v.findViewById(R.id.items_list); recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); @@ -101,7 +101,7 @@ public class SelectChannelFragment extends DialogFragment { emptyView.setVisibility(View.GONE); - subscriptionService = SubscriptionService.getInstance(); + subscriptionService = SubscriptionService.getInstance(getContext()); subscriptionService.getSubscription().toObservable() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java index 167b6f31b..00b618889 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java @@ -13,6 +13,7 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; +import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.ServiceList; @@ -45,6 +46,8 @@ import java.util.Vector; public class SelectKioskFragment extends DialogFragment { + private static final boolean DEBUG = MainActivity.DEBUG; + RecyclerView recyclerView = null; SelectKioskAdapter selectKioskAdapter = null; @@ -122,11 +125,11 @@ public class SelectKioskFragment extends DialogFragment { for(StreamingService service : NewPipe.getServices()) { //TODO: Multi-service support - if (service.getServiceId() != ServiceList.YouTube.getId()) continue; + if (service.getServiceId() != ServiceList.YouTube.getServiceId() && !DEBUG) continue; for(String kioskId : service.getKioskList().getAvailableKiosks()) { String name = String.format(getString(R.string.service_kiosk_string), - service.getServiceInfo().name, + service.getServiceInfo().getName(), KioskTranslator.getTranslatedKioskName(kioskId, getContext())); kioskList.add(new Entry( ServiceHelper.getIcon(service.getServiceId()), diff --git a/app/src/main/java/org/schabi/newpipe/subscription/ImportExportEventListener.java b/app/src/main/java/org/schabi/newpipe/subscription/ImportExportEventListener.java new file mode 100644 index 000000000..7560a2265 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/subscription/ImportExportEventListener.java @@ -0,0 +1,17 @@ +package org.schabi.newpipe.subscription; + +public interface ImportExportEventListener { + /** + * Called when the size has been resolved. + * + * @param size how many items there are to import/export + */ + void onSizeReceived(int size); + + /** + * Called everytime an item has been parsed/resolved. + * + * @param itemName the name of the subscription item + */ + void onItemCompleted(String itemName); +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/subscription/ImportExportJsonHelper.java b/app/src/main/java/org/schabi/newpipe/subscription/ImportExportJsonHelper.java new file mode 100644 index 000000000..04f402438 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/subscription/ImportExportJsonHelper.java @@ -0,0 +1,138 @@ +/* + * Copyright 2018 Mauricio Colli + * ImportExportJsonHelper.java is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.schabi.newpipe.subscription; + +import android.support.annotation.Nullable; + +import com.grack.nanojson.JsonAppendableWriter; +import com.grack.nanojson.JsonArray; +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonParser; +import com.grack.nanojson.JsonSink; +import com.grack.nanojson.JsonWriter; + +import org.schabi.newpipe.BuildConfig; +import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException; +import org.schabi.newpipe.extractor.subscription.SubscriptionItem; + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * A JSON implementation capable of importing and exporting subscriptions, it has the advantage + * of being able to transfer subscriptions to any device. + */ +public class ImportExportJsonHelper { + + /*////////////////////////////////////////////////////////////////////////// + // Json implementation + //////////////////////////////////////////////////////////////////////////*/ + + private static final String JSON_APP_VERSION_KEY = "app_version"; + private static final String JSON_APP_VERSION_INT_KEY = "app_version_int"; + + private static final String JSON_SUBSCRIPTIONS_ARRAY_KEY = "subscriptions"; + + private static final String JSON_SERVICE_ID_KEY = "service_id"; + private static final String JSON_URL_KEY = "url"; + private static final String JSON_NAME_KEY = "name"; + + /** + * Read a JSON source through the input stream and return the parsed subscription items. + * + * @param in the input stream (e.g. a file) + * @param eventListener listener for the events generated + */ + public static List readFrom(InputStream in, @Nullable ImportExportEventListener eventListener) throws InvalidSourceException { + if (in == null) throw new InvalidSourceException("input is null"); + + final List channels = new ArrayList<>(); + + try { + JsonObject parentObject = JsonParser.object().from(in); + JsonArray channelsArray = parentObject.getArray(JSON_SUBSCRIPTIONS_ARRAY_KEY); + if (eventListener != null) eventListener.onSizeReceived(channelsArray.size()); + + if (channelsArray == null) { + throw new InvalidSourceException("Channels array is null"); + } + + for (Object o : channelsArray) { + if (o instanceof JsonObject) { + JsonObject itemObject = (JsonObject) o; + int serviceId = itemObject.getInt(JSON_SERVICE_ID_KEY, 0); + String url = itemObject.getString(JSON_URL_KEY); + String name = itemObject.getString(JSON_NAME_KEY); + + if (url != null && name != null && !url.isEmpty() && !name.isEmpty()) { + channels.add(new SubscriptionItem(serviceId, url, name)); + if (eventListener != null) eventListener.onItemCompleted(name); + } + } + } + } catch (Throwable e) { + throw new InvalidSourceException("Couldn't parse json", e); + } + + return channels; + } + + /** + * Write the subscriptions items list as JSON to the output. + * + * @param items the list of subscriptions items + * @param out the output stream (e.g. a file) + * @param eventListener listener for the events generated + */ + public static void writeTo(List items, OutputStream out, @Nullable ImportExportEventListener eventListener) { + JsonAppendableWriter writer = JsonWriter.on(out); + writeTo(items, writer, eventListener); + writer.done(); + } + + /** + * @see #writeTo(List, OutputStream, ImportExportEventListener) + */ + public static void writeTo(List items, JsonSink writer, @Nullable ImportExportEventListener eventListener) { + if (eventListener != null) eventListener.onSizeReceived(items.size()); + + writer.object(); + + writer.value(JSON_APP_VERSION_KEY, BuildConfig.VERSION_NAME); + writer.value(JSON_APP_VERSION_INT_KEY, BuildConfig.VERSION_CODE); + + writer.array(JSON_SUBSCRIPTIONS_ARRAY_KEY); + for (SubscriptionItem item : items) { + writer.object(); + writer.value(JSON_SERVICE_ID_KEY, item.getServiceId()); + writer.value(JSON_URL_KEY, item.getUrl()); + writer.value(JSON_NAME_KEY, item.getName()); + writer.end(); + + if (eventListener != null) eventListener.onItemCompleted(item.getName()); + } + writer.end(); + + writer.end(); + } + +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionService.java b/app/src/main/java/org/schabi/newpipe/subscription/SubscriptionService.java similarity index 82% rename from app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionService.java rename to app/src/main/java/org/schabi/newpipe/subscription/SubscriptionService.java index c183f5889..3220643b3 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionService.java +++ b/app/src/main/java/org/schabi/newpipe/subscription/SubscriptionService.java @@ -1,5 +1,7 @@ -package org.schabi.newpipe.fragments.subscription; +package org.schabi.newpipe.subscription; +import android.content.Context; +import android.support.annotation.NonNull; import android.util.Log; import org.schabi.newpipe.MainActivity; @@ -10,6 +12,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.util.ExtractorHelper; +import java.util.ArrayList; import java.util.List; import java.util.concurrent.Executor; import java.util.concurrent.Executors; @@ -20,7 +23,6 @@ import io.reactivex.CompletableSource; import io.reactivex.Flowable; import io.reactivex.Maybe; import io.reactivex.Scheduler; -import io.reactivex.annotations.NonNull; import io.reactivex.functions.Function; import io.reactivex.schedulers.Schedulers; @@ -32,10 +34,20 @@ import io.reactivex.schedulers.Schedulers; */ public class SubscriptionService { - private static final SubscriptionService sInstance = new SubscriptionService(); + private static volatile SubscriptionService instance; - public static SubscriptionService getInstance() { - return sInstance; + public static SubscriptionService getInstance(@NonNull Context context) { + SubscriptionService result = instance; + if (result == null) { + synchronized (SubscriptionService.class) { + result = instance; + if (result == null) { + instance = (result = new SubscriptionService(context)); + } + } + } + + return result; } protected final String TAG = "SubscriptionService@" + Integer.toHexString(hashCode()); @@ -48,8 +60,8 @@ public class SubscriptionService { private Scheduler subscriptionScheduler; - private SubscriptionService() { - db = NewPipeDatabase.getInstance(); + private SubscriptionService(Context context) { + db = NewPipeDatabase.getInstance(context.getApplicationContext()); subscription = getSubscriptionInfos(); final Executor subscriptionExecutor = Executors.newFixedThreadPool(SUBSCRIPTION_THREAD_POOL_SIZE); @@ -114,7 +126,7 @@ public class SubscriptionService { if (!isSubscriptionUpToDate(info, subscription)) { subscription.setData(info.getName(), info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount()); - return update(subscription); + return Completable.fromRunnable(() -> subscriptionTable().update(subscription)); } } @@ -127,13 +139,11 @@ public class SubscriptionService { .flatMapCompletable(update); } - private Completable update(final SubscriptionEntity updatedSubscription) { - return Completable.fromRunnable(new Runnable() { - @Override - public void run() { - subscriptionTable().update(updatedSubscription); - } - }); + public List upsertAll(final List infoList) { + final List entityList = new ArrayList<>(); + for (ChannelInfo info : infoList) entityList.add(SubscriptionEntity.from(info)); + + return subscriptionTable().upsertAll(entityList); } private boolean isSubscriptionUpToDate(final ChannelInfo info, final SubscriptionEntity entity) { diff --git a/app/src/main/java/org/schabi/newpipe/subscription/services/BaseImportExportService.java b/app/src/main/java/org/schabi/newpipe/subscription/services/BaseImportExportService.java new file mode 100644 index 000000000..a26b7a6d1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/subscription/services/BaseImportExportService.java @@ -0,0 +1,227 @@ +/* + * Copyright 2018 Mauricio Colli + * BaseImportExportService.java is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.schabi.newpipe.subscription.services; + +import android.app.Service; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.StringRes; +import android.support.v4.app.NotificationCompat; +import android.support.v4.app.NotificationManagerCompat; +import android.text.TextUtils; +import android.widget.Toast; + +import org.reactivestreams.Publisher; +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; +import org.schabi.newpipe.report.ErrorActivity; +import org.schabi.newpipe.report.UserAction; +import org.schabi.newpipe.subscription.ImportExportEventListener; +import org.schabi.newpipe.subscription.SubscriptionService; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.Collections; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import io.reactivex.Flowable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.functions.Function; +import io.reactivex.processors.PublishProcessor; + +public abstract class BaseImportExportService extends Service { + protected final String TAG = this.getClass().getSimpleName(); + + protected NotificationManagerCompat notificationManager; + protected NotificationCompat.Builder notificationBuilder; + + protected SubscriptionService subscriptionService; + protected CompositeDisposable disposables = new CompositeDisposable(); + protected PublishProcessor notificationUpdater = PublishProcessor.create(); + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onCreate() { + super.onCreate(); + subscriptionService = SubscriptionService.getInstance(this); + setupNotification(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + disposeAll(); + } + + protected void disposeAll() { + disposables.clear(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Notification Impl + //////////////////////////////////////////////////////////////////////////*/ + + private static final int NOTIFICATION_SAMPLING_PERIOD = 2500; + + protected AtomicInteger currentProgress = new AtomicInteger(-1); + protected AtomicInteger maxProgress = new AtomicInteger(-1); + protected ImportExportEventListener eventListener = new ImportExportEventListener() { + @Override + public void onSizeReceived(int size) { + maxProgress.set(size); + currentProgress.set(0); + } + + @Override + public void onItemCompleted(String itemName) { + currentProgress.incrementAndGet(); + notificationUpdater.onNext(itemName); + } + }; + + protected abstract int getNotificationId(); + @StringRes + public abstract int getTitle(); + + protected void setupNotification() { + notificationManager = NotificationManagerCompat.from(this); + notificationBuilder = createNotification(); + startForeground(getNotificationId(), notificationBuilder.build()); + + final Function, Publisher> throttleAfterFirstEmission = flow -> flow.limit(1) + .concatWith(flow.skip(1).throttleLast(NOTIFICATION_SAMPLING_PERIOD, TimeUnit.MILLISECONDS)); + + disposables.add(notificationUpdater + .filter(s -> !s.isEmpty()) + .publish(throttleAfterFirstEmission) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::updateNotification)); + } + + protected void updateNotification(String text) { + notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1); + + final String progressText = currentProgress + "/" + maxProgress; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (!TextUtils.isEmpty(text)) text = text + " (" + progressText + ")"; + } else { + notificationBuilder.setContentInfo(progressText); + } + + if (!TextUtils.isEmpty(text)) notificationBuilder.setContentText(text); + notificationManager.notify(getNotificationId(), notificationBuilder.build()); + } + + protected void stopService() { + postErrorResult(null, null); + } + + protected void stopAndReportError(@Nullable Throwable error, String request) { + stopService(); + + final ErrorActivity.ErrorInfo errorInfo = ErrorActivity.ErrorInfo.make(UserAction.SUBSCRIPTION, "unknown", + request, R.string.general_error); + ErrorActivity.reportError(this, error != null ? Collections.singletonList(error) : Collections.emptyList(), + null, null, errorInfo); + } + + protected void postErrorResult(String title, String text) { + disposeAll(); + stopForeground(true); + stopSelf(); + + if (title == null) { + return; + } + + text = text == null ? "" : text; + notificationBuilder = new NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentTitle(title) + .setStyle(new NotificationCompat.BigTextStyle().bigText(text)) + .setContentText(text); + notificationManager.notify(getNotificationId(), notificationBuilder.build()); + } + + protected NotificationCompat.Builder createNotification() { + return new NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) + .setOngoing(true) + .setProgress(-1, -1, true) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentTitle(getString(getTitle())); + } + + /*////////////////////////////////////////////////////////////////////////// + // Toast + //////////////////////////////////////////////////////////////////////////*/ + + protected Toast toast; + + protected void showToast(@StringRes int message) { + showToast(getString(message), Toast.LENGTH_SHORT); + } + + protected void showToast(String message, int duration) { + if (toast != null) toast.cancel(); + + toast = Toast.makeText(this, message, duration); + toast.show(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Error handling + //////////////////////////////////////////////////////////////////////////*/ + + protected void handleError(@StringRes int errorTitle, @NonNull Throwable error) { + String message = getErrorMessage(error); + + if (TextUtils.isEmpty(message)) { + final String errorClassName = error.getClass().getName(); + message = getString(R.string.error_occurred_detail, errorClassName); + } + + showToast(errorTitle); + postErrorResult(getString(errorTitle), message); + } + + protected String getErrorMessage(Throwable error) { + String message = null; + if (error instanceof SubscriptionExtractor.InvalidSourceException) { + message = getString(R.string.invalid_source); + } else if (error instanceof FileNotFoundException) { + message = getString(R.string.invalid_file); + } else if (error instanceof IOException) { + message = getString(R.string.network_error); + } + return message; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/subscription/services/SubscriptionsExportService.java b/app/src/main/java/org/schabi/newpipe/subscription/services/SubscriptionsExportService.java new file mode 100644 index 000000000..069195c65 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/subscription/services/SubscriptionsExportService.java @@ -0,0 +1,153 @@ +/* + * Copyright 2018 Mauricio Colli + * SubscriptionsExportService.java is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.schabi.newpipe.subscription.services; + +import android.content.Intent; +import android.support.v4.content.LocalBroadcastManager; +import android.text.TextUtils; +import android.util.Log; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.subscription.SubscriptionEntity; +import org.schabi.newpipe.extractor.subscription.SubscriptionItem; +import org.schabi.newpipe.subscription.ImportExportJsonHelper; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.util.ArrayList; +import java.util.List; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.functions.Function; +import io.reactivex.schedulers.Schedulers; + +import static org.schabi.newpipe.MainActivity.DEBUG; + +public class SubscriptionsExportService extends BaseImportExportService { + public static final String KEY_FILE_PATH = "key_file_path"; + + /** + * A {@link LocalBroadcastManager local broadcast} will be made with this action when the export is successfully completed. + */ + public static final String EXPORT_COMPLETE_ACTION = "org.schabi.newpipe.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE"; + + private Subscription subscription; + private File outFile; + private FileOutputStream outputStream; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null || subscription != null) return START_NOT_STICKY; + + final String path = intent.getStringExtra(KEY_FILE_PATH); + if (TextUtils.isEmpty(path)) { + stopAndReportError(new IllegalStateException("Exporting to a file, but the path is empty or null"), "Exporting subscriptions"); + return START_NOT_STICKY; + } + + try { + outputStream = new FileOutputStream(outFile = new File(path)); + } catch (FileNotFoundException e) { + handleError(e); + return START_NOT_STICKY; + } + + startExport(); + + return START_NOT_STICKY; + } + + @Override + protected int getNotificationId() { + return 4567; + } + + @Override + public int getTitle() { + return R.string.export_ongoing; + } + + @Override + protected void disposeAll() { + super.disposeAll(); + if (subscription != null) subscription.cancel(); + } + + private void startExport() { + showToast(R.string.export_ongoing); + + subscriptionService.subscriptionTable() + .getAll() + .take(1) + .map(subscriptionEntities -> { + final List result = new ArrayList<>(subscriptionEntities.size()); + for (SubscriptionEntity entity : subscriptionEntities) { + result.add(new SubscriptionItem(entity.getServiceId(), entity.getUrl(), entity.getName())); + } + return result; + }) + .map(exportToFile()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getSubscriber()); + } + + private Subscriber getSubscriber() { + return new Subscriber() { + @Override + public void onSubscribe(Subscription s) { + subscription = s; + s.request(1); + } + + @Override + public void onNext(File file) { + if (DEBUG) Log.d(TAG, "startExport() success: file = " + file); + } + + @Override + public void onError(Throwable error) { + Log.e(TAG, "onError() called with: error = [" + error + "]", error); + handleError(error); + } + + @Override + public void onComplete() { + LocalBroadcastManager.getInstance(SubscriptionsExportService.this).sendBroadcast(new Intent(EXPORT_COMPLETE_ACTION)); + showToast(R.string.export_complete_toast); + stopService(); + } + }; + } + + private Function, File> exportToFile() { + return subscriptionItems -> { + ImportExportJsonHelper.writeTo(subscriptionItems, outputStream, eventListener); + return outFile; + }; + } + + protected void handleError(Throwable error) { + super.handleError(R.string.subscriptions_export_unsuccessful, error); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/subscription/services/SubscriptionsImportService.java b/app/src/main/java/org/schabi/newpipe/subscription/services/SubscriptionsImportService.java new file mode 100644 index 000000000..259b1c2bd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/subscription/services/SubscriptionsImportService.java @@ -0,0 +1,264 @@ +/* + * Copyright 2018 Mauricio Colli + * SubscriptionsImportService.java is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.schabi.newpipe.subscription.services; + +import android.content.Intent; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.content.LocalBroadcastManager; +import android.text.TextUtils; +import android.util.Log; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.subscription.SubscriptionEntity; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.channel.ChannelInfo; +import org.schabi.newpipe.extractor.subscription.SubscriptionItem; +import org.schabi.newpipe.subscription.ImportExportJsonHelper; +import org.schabi.newpipe.util.Constants; +import org.schabi.newpipe.util.ExtractorHelper; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import io.reactivex.Flowable; +import io.reactivex.Notification; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.functions.Consumer; +import io.reactivex.functions.Function; +import io.reactivex.schedulers.Schedulers; + +import static org.schabi.newpipe.MainActivity.DEBUG; + +public class SubscriptionsImportService extends BaseImportExportService { + public static final int CHANNEL_URL_MODE = 0; + public static final int INPUT_STREAM_MODE = 1; + public static final int PREVIOUS_EXPORT_MODE = 2; + public static final String KEY_MODE = "key_mode"; + public static final String KEY_VALUE = "key_value"; + + /** + * A {@link LocalBroadcastManager local broadcast} will be made with this action when the import is successfully completed. + */ + public static final String IMPORT_COMPLETE_ACTION = "org.schabi.newpipe.subscription.services.SubscriptionsImportService.IMPORT_COMPLETE"; + + private Subscription subscription; + private int currentMode; + private int currentServiceId; + + @Nullable + private String channelUrl; + @Nullable + private InputStream inputStream; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null || subscription != null) return START_NOT_STICKY; + + currentMode = intent.getIntExtra(KEY_MODE, -1); + currentServiceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, Constants.NO_SERVICE_ID); + + if (currentMode == CHANNEL_URL_MODE) { + channelUrl = intent.getStringExtra(KEY_VALUE); + } else { + final String filePath = intent.getStringExtra(KEY_VALUE); + if (TextUtils.isEmpty(filePath)) { + stopAndReportError(new IllegalStateException("Importing from input stream, but file path is empty or null"), "Importing subscriptions"); + return START_NOT_STICKY; + } + + try { + inputStream = new FileInputStream(new File(filePath)); + } catch (FileNotFoundException e) { + handleError(e); + return START_NOT_STICKY; + } + } + + if (currentMode == -1 || currentMode == CHANNEL_URL_MODE && channelUrl == null) { + final String errorDescription = "Some important field is null or in illegal state: currentMode=[" + currentMode + "], channelUrl=[" + channelUrl + "], inputStream=[" + inputStream + "]"; + stopAndReportError(new IllegalStateException(errorDescription), "Importing subscriptions"); + return START_NOT_STICKY; + } + + startImport(); + return START_NOT_STICKY; + } + + @Override + protected int getNotificationId() { + return 4568; + } + + @Override + public int getTitle() { + return R.string.import_ongoing; + } + + @Override + protected void disposeAll() { + super.disposeAll(); + if (subscription != null) subscription.cancel(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Imports + //////////////////////////////////////////////////////////////////////////*/ + + /** + * How many extractions running in parallel. + */ + public static final int PARALLEL_EXTRACTIONS = 8; + + /** + * Number of items to buffer to mass-insert in the subscriptions table, this leads to + * a better performance as we can then use db transactions. + */ + public static final int BUFFER_COUNT_BEFORE_INSERT = 50; + + private void startImport() { + showToast(R.string.import_ongoing); + + Flowable> flowable = null; + if (currentMode == CHANNEL_URL_MODE) { + flowable = importFromChannelUrl(); + } else if (currentMode == INPUT_STREAM_MODE) { + flowable = importFromInputStream(); + } else if (currentMode == PREVIOUS_EXPORT_MODE) { + flowable = importFromPreviousExport(); + } + + if (flowable == null) { + final String message = "Flowable given by \"importFrom\" is null (current mode: " + currentMode + ")"; + stopAndReportError(new IllegalStateException(message), "Importing subscriptions"); + return; + } + + flowable.doOnNext(subscriptionItems -> eventListener.onSizeReceived(subscriptionItems.size())) + .flatMap(Flowable::fromIterable) + + .parallel(PARALLEL_EXTRACTIONS) + .runOn(Schedulers.io()) + .map((Function>) subscriptionItem -> { + try { + return Notification.createOnNext(ExtractorHelper + .getChannelInfo(subscriptionItem.getServiceId(), subscriptionItem.getUrl(), true) + .blockingGet()); + } catch (Throwable e) { + return Notification.createOnError(e); + } + }) + .sequential() + + .observeOn(Schedulers.io()) + .doOnNext(getNotificationsConsumer()) + .buffer(BUFFER_COUNT_BEFORE_INSERT) + .map(upsertBatch()) + + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getSubscriber()); + } + + private Subscriber> getSubscriber() { + return new Subscriber>() { + + @Override + public void onSubscribe(Subscription s) { + subscription = s; + s.request(Long.MAX_VALUE); + } + + @Override + public void onNext(List successfulInserted) { + if (DEBUG) Log.d(TAG, "startImport() " + successfulInserted.size() + " items successfully inserted into the database"); + } + + @Override + public void onError(Throwable error) { + handleError(error); + } + + @Override + public void onComplete() { + LocalBroadcastManager.getInstance(SubscriptionsImportService.this).sendBroadcast(new Intent(IMPORT_COMPLETE_ACTION)); + showToast(R.string.import_complete_toast); + stopService(); + } + }; + } + + private Consumer> getNotificationsConsumer() { + return notification -> { + if (notification.isOnNext()) { + String name = notification.getValue().getName(); + eventListener.onItemCompleted(!TextUtils.isEmpty(name) ? name : ""); + } else if (notification.isOnError()) { + final Throwable error = notification.getError(); + final Throwable cause = error.getCause(); + if (error instanceof IOException) { + throw (IOException) error; + } else if (cause != null && cause instanceof IOException) { + throw (IOException) cause; + } + + eventListener.onItemCompleted(""); + } + }; + } + + private Function>, List> upsertBatch() { + return notificationList -> { + final List infoList = new ArrayList<>(notificationList.size()); + for (Notification n : notificationList) { + if (n.isOnNext()) infoList.add(n.getValue()); + } + + return subscriptionService.upsertAll(infoList); + }; + } + + private Flowable> importFromChannelUrl() { + return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) + .getSubscriptionExtractor() + .fromChannelUrl(channelUrl)); + } + + private Flowable> importFromInputStream() { + return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) + .getSubscriptionExtractor() + .fromInputStream(inputStream)); + } + + private Flowable> importFromPreviousExport() { + return Flowable.fromCallable(() -> ImportExportJsonHelper.readFrom(inputStream, null)); + } + + protected void handleError(@NonNull Throwable error) { + super.handleError(R.string.subscriptions_import_unsuccessful, error); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/util/AnimationUtils.java b/app/src/main/java/org/schabi/newpipe/util/AnimationUtils.java index c954211fa..3c5f16929 100644 --- a/app/src/main/java/org/schabi/newpipe/util/AnimationUtils.java +++ b/app/src/main/java/org/schabi/newpipe/util/AnimationUtils.java @@ -1,3 +1,22 @@ +/* + * Copyright 2018 Mauricio Colli + * AnimationUtils.java is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.schabi.newpipe.util; import android.animation.Animator; @@ -19,7 +38,9 @@ public class AnimationUtils { private static final boolean DEBUG = MainActivity.DEBUG; public enum Type { - ALPHA, SCALE_AND_ALPHA, LIGHT_SCALE_AND_ALPHA, SLIDE_AND_ALPHA, LIGHT_SLIDE_AND_ALPHA + ALPHA, + SCALE_AND_ALPHA, LIGHT_SCALE_AND_ALPHA, + SLIDE_AND_ALPHA, LIGHT_SLIDE_AND_ALPHA } public static void animateView(View view, boolean enterOrExit, long duration) { @@ -168,6 +189,58 @@ public class AnimationUtils { viewPropertyAnimator.start(); } + public static ValueAnimator animateHeight(final View view, long duration, int targetHeight) { + final int height = view.getHeight(); + if (DEBUG) { + Log.d(TAG, "animateHeight: duration = [" + duration + "], from " + height + " to → " + targetHeight + " in: " + view); + } + + ValueAnimator animator = ValueAnimator.ofFloat(height, targetHeight); + animator.setInterpolator(new FastOutSlowInInterpolator()); + animator.setDuration(duration); + animator.addUpdateListener(animation -> { + final float value = (float) animation.getAnimatedValue(); + view.getLayoutParams().height = (int) value; + view.requestLayout(); + }); + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + view.getLayoutParams().height = targetHeight; + view.requestLayout(); + } + + @Override + public void onAnimationCancel(Animator animation) { + view.getLayoutParams().height = targetHeight; + view.requestLayout(); + } + }); + animator.start(); + + return animator; + } + + public static void animateRotation(final View view, long duration, int targetRotation) { + if (DEBUG) { + Log.d(TAG, "animateRotation: duration = [" + duration + "], from " + view.getRotation() + " to → " + targetRotation + " in: " + view); + } + view.animate().setListener(null).cancel(); + + view.animate().rotation(targetRotation).setDuration(duration).setInterpolator(new FastOutSlowInInterpolator()) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationCancel(Animator animation) { + view.setRotation(targetRotation); + } + + @Override + public void onAnimationEnd(Animator animation) { + view.setRotation(targetRotation); + } + }).start(); + } + /*////////////////////////////////////////////////////////////////////////// // Internals //////////////////////////////////////////////////////////////////////////*/ diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java index 8e755b922..1897589c6 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -29,7 +29,7 @@ import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.ReCaptchaActivity; import org.schabi.newpipe.extractor.Info; -import org.schabi.newpipe.extractor.ListExtractor.NextItemsResult; +import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; @@ -78,7 +78,7 @@ public final class ExtractorHelper { ); } - public static Single getMoreSearchItems(final int serviceId, + public static Single getMoreSearchItems(final int serviceId, final String query, final int nextPageNumber, final String searchLanguage, @@ -86,7 +86,7 @@ public final class ExtractorHelper { checkServiceId(serviceId); return searchFor(serviceId, query, nextPageNumber, searchLanguage, filter) .map((@NonNull SearchResult searchResult) -> - new NextItemsResult(searchResult.resultList, + new InfoItemsPage(searchResult.resultList, nextPageNumber + "", searchResult.errors)); } @@ -117,7 +117,7 @@ public final class ExtractorHelper { ChannelInfo.getInfo(NewPipe.getService(serviceId), url))); } - public static Single getMoreChannelItems(final int serviceId, + public static Single getMoreChannelItems(final int serviceId, final String url, final String nextStreamsUrl) { checkServiceId(serviceId); @@ -133,7 +133,7 @@ public final class ExtractorHelper { PlaylistInfo.getInfo(NewPipe.getService(serviceId), url))); } - public static Single getMorePlaylistItems(final int serviceId, + public static Single getMorePlaylistItems(final int serviceId, final String url, final String nextStreamsUrl) { checkServiceId(serviceId); @@ -149,7 +149,7 @@ public final class ExtractorHelper { KioskInfo.getInfo(NewPipe.getService(serviceId), url, contentCountry))); } - public static Single getMoreKioskItems(final int serviceId, + public static Single getMoreKioskItems(final int serviceId, final String url, final String nextStreamsUrl, final String contentCountry) { @@ -172,7 +172,7 @@ public final class ExtractorHelper { String url, Single loadFromNetwork) { checkServiceId(serviceId); - loadFromNetwork = loadFromNetwork.doOnSuccess((@NonNull I i) -> cache.putInfo(i)); + loadFromNetwork = loadFromNetwork.doOnSuccess(info -> cache.putInfo(serviceId, url, info)); Single load; if (forceLoad) { @@ -224,8 +224,6 @@ public final class ExtractorHelper { Toast.makeText(context, R.string.network_error, Toast.LENGTH_LONG).show(); } else if (exception instanceof YoutubeStreamExtractor.GemaException) { Toast.makeText(context, R.string.blocked_by_gema, Toast.LENGTH_LONG).show(); - } else if (exception instanceof YoutubeStreamExtractor.LiveStreamException) { - Toast.makeText(context, R.string.live_streams_not_supported, Toast.LENGTH_LONG).show(); } else if (exception instanceof ContentNotAvailableException) { Toast.makeText(context, R.string.content_not_available, Toast.LENGTH_LONG).show(); } else { diff --git a/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java b/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java index 5f588c5ca..20554ce59 100644 --- a/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java @@ -1,10 +1,32 @@ package org.schabi.newpipe.util; +import android.content.Context; +import android.content.Intent; import android.os.Bundle; +import android.os.Environment; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.content.Loader; +import android.support.v7.util.SortedList; +import android.support.v7.widget.RecyclerView; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import com.nononsenseapps.filepicker.AbstractFilePickerFragment; +import com.nononsenseapps.filepicker.FilePickerFragment; + import org.schabi.newpipe.R; +import java.io.File; + public class FilePickerActivityHelper extends com.nononsenseapps.filepicker.FilePickerActivity { + private CustomFilePickerFragment currentFragment; + @Override public void onCreate(Bundle savedInstanceState) { if(ThemeHelper.isLightThemeSelected(this)) { @@ -14,4 +36,98 @@ public class FilePickerActivityHelper extends com.nononsenseapps.filepicker.File } super.onCreate(savedInstanceState); } + + @Override + public void onBackPressed() { + // If at top most level, normal behaviour + if (currentFragment.isBackTop()) { + super.onBackPressed(); + } else { + // Else go up + currentFragment.goUp(); + } + } + + @Override + protected AbstractFilePickerFragment getFragment(@Nullable String startPath, int mode, boolean allowMultiple, boolean allowCreateDir, boolean allowExistingFile, boolean singleClick) { + final CustomFilePickerFragment fragment = new CustomFilePickerFragment(); + fragment.setArgs(startPath != null ? startPath : Environment.getExternalStorageDirectory().getPath(), + mode, allowMultiple, allowCreateDir, allowExistingFile, singleClick); + return currentFragment = fragment; + } + + public static Intent chooseSingleFile(@NonNull Context context) { + return new Intent(context, FilePickerActivityHelper.class) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, false) + .putExtra(FilePickerActivityHelper.EXTRA_SINGLE_CLICK, true) + .putExtra(FilePickerActivityHelper.EXTRA_MODE, FilePickerActivityHelper.MODE_FILE); + } + + public static Intent chooseFileToSave(@NonNull Context context, @Nullable String startPath) { + return new Intent(context, FilePickerActivityHelper.class) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_EXISTING_FILE, true) + .putExtra(FilePickerActivityHelper.EXTRA_START_PATH, startPath) + .putExtra(FilePickerActivityHelper.EXTRA_MODE, FilePickerActivityHelper.MODE_NEW_FILE); + } + + /*////////////////////////////////////////////////////////////////////////// + // Internal + //////////////////////////////////////////////////////////////////////////*/ + + public static class CustomFilePickerFragment extends FilePickerFragment { + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + return super.onCreateView(inflater, container, savedInstanceState); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + final RecyclerView.ViewHolder viewHolder = super.onCreateViewHolder(parent, viewType); + + final View view = viewHolder.itemView.findViewById(android.R.id.text1); + if (view instanceof TextView) { + ((TextView) view).setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimension(R.dimen.file_picker_items_text_size)); + } + + return viewHolder; + } + + @Override + public void onClickOk(@NonNull View view) { + if (mode == MODE_NEW_FILE && getNewFileName().isEmpty()) { + if (mToast != null) mToast.cancel(); + mToast = Toast.makeText(getActivity(), R.string.file_name_empty_error, Toast.LENGTH_SHORT); + mToast.show(); + return; + } + + super.onClickOk(view); + } + + public File getBackTop() { + if (getArguments() == null) return Environment.getExternalStorageDirectory(); + + final String path = getArguments().getString(KEY_START_PATH, "/"); + if (path.contains(Environment.getExternalStorageDirectory().getPath())) { + return Environment.getExternalStorageDirectory(); + } + + return getPath(path); + } + + public boolean isBackTop() { + return compareFiles(mCurrentPath, getBackTop()) == 0 || compareFiles(mCurrentPath, new File("/")) == 0; + } + + @Override + public void onLoadFinished(Loader> loader, SortedList data) { + super.onLoadFinished(loader, data); + layoutManager.scrollToPosition(0); + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/util/ImageDisplayConstants.java b/app/src/main/java/org/schabi/newpipe/util/ImageDisplayConstants.java new file mode 100644 index 000000000..9ee8a1095 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/ImageDisplayConstants.java @@ -0,0 +1,58 @@ +package org.schabi.newpipe.util; + +import android.graphics.Bitmap; + +import com.nostra13.universalimageloader.core.DisplayImageOptions; +import com.nostra13.universalimageloader.core.assist.ImageScaleType; +import com.nostra13.universalimageloader.core.display.FadeInBitmapDisplayer; + +import org.schabi.newpipe.R; + +public class ImageDisplayConstants { + private static final int BITMAP_FADE_IN_DURATION_MILLIS = 250; + + /** + * Base display options + */ + private static final DisplayImageOptions BASE_DISPLAY_IMAGE_OPTIONS = + new DisplayImageOptions.Builder() + .cacheInMemory(true) + .cacheOnDisk(true) + .resetViewBeforeLoading(true) + .bitmapConfig(Bitmap.Config.RGB_565) + .imageScaleType(ImageScaleType.EXACTLY) + .displayer(new FadeInBitmapDisplayer(BITMAP_FADE_IN_DURATION_MILLIS)) + .build(); + + /*////////////////////////////////////////////////////////////////////////// + // DisplayImageOptions default configurations + //////////////////////////////////////////////////////////////////////////*/ + + public static final DisplayImageOptions DISPLAY_AVATAR_OPTIONS = + new DisplayImageOptions.Builder() + .cloneFrom(BASE_DISPLAY_IMAGE_OPTIONS) + .showImageForEmptyUri(R.drawable.buddy) + .showImageOnFail(R.drawable.buddy) + .build(); + + public static final DisplayImageOptions DISPLAY_THUMBNAIL_OPTIONS = + new DisplayImageOptions.Builder() + .cloneFrom(BASE_DISPLAY_IMAGE_OPTIONS) + .showImageForEmptyUri(R.drawable.dummy_thumbnail) + .showImageOnFail(R.drawable.dummy_thumbnail) + .build(); + + public static final DisplayImageOptions DISPLAY_BANNER_OPTIONS = + new DisplayImageOptions.Builder() + .cloneFrom(BASE_DISPLAY_IMAGE_OPTIONS) + .showImageForEmptyUri(R.drawable.channel_banner) + .showImageOnFail(R.drawable.channel_banner) + .build(); + + public static final DisplayImageOptions DISPLAY_PLAYLIST_OPTIONS = + new DisplayImageOptions.Builder() + .cloneFrom(BASE_DISPLAY_IMAGE_OPTIONS) + .showImageForEmptyUri(R.drawable.dummy_thumbnail_playlist) + .showImageOnFail(R.drawable.dummy_thumbnail_playlist) + .build(); +} diff --git a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java index 0f082cc11..ecc66bb40 100644 --- a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java +++ b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java @@ -20,6 +20,7 @@ package org.schabi.newpipe.util; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.v4.util.LruCache; import android.util.Log; @@ -29,6 +30,8 @@ import org.schabi.newpipe.extractor.Info; import java.util.Map; import java.util.concurrent.TimeUnit; +import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; + public final class InfoCache { private static final boolean DEBUG = MainActivity.DEBUG; @@ -40,7 +43,6 @@ public final class InfoCache { * Trim the cache to this size */ private static final int TRIM_CACHE_TO = 30; - private static final int DEFAULT_TIMEOUT_HOURS = 4; private static final LruCache lruCache = new LruCache<>(MAX_ITEMS_ON_CACHE); @@ -52,6 +54,7 @@ public final class InfoCache { return instance; } + @Nullable public Info getFromKey(int serviceId, @NonNull String url) { if (DEBUG) Log.d(TAG, "getFromKey() called with: serviceId = [" + serviceId + "], url = [" + url + "]"); synchronized (lruCache) { @@ -59,18 +62,13 @@ public final class InfoCache { } } - public void putInfo(@NonNull Info info) { + public void putInfo(int serviceId, @NonNull String url, @NonNull Info info) { if (DEBUG) Log.d(TAG, "putInfo() called with: info = [" + info + "]"); - synchronized (lruCache) { - final CacheData data = new CacheData(info, DEFAULT_TIMEOUT_HOURS, TimeUnit.HOURS); - lruCache.put(keyOf(info), data); - } - } - public void removeInfo(@NonNull Info info) { - if (DEBUG) Log.d(TAG, "removeInfo() called with: info = [" + info + "]"); + final long expirationMillis = ServiceHelper.getCacheExpirationMillis(info.getServiceId()); synchronized (lruCache) { - lruCache.remove(keyOf(info)); + final CacheData data = new CacheData(info, expirationMillis); + lruCache.put(keyOf(serviceId, url), data); } } @@ -102,10 +100,7 @@ public final class InfoCache { } } - private static String keyOf(@NonNull final Info info) { - return keyOf(info.getServiceId(), info.getUrl()); - } - + @NonNull private static String keyOf(final int serviceId, @NonNull final String url) { return serviceId + url; } @@ -119,6 +114,7 @@ public final class InfoCache { } } + @Nullable private static Info getInfo(@NonNull final LruCache cache, @NonNull final String key) { final CacheData data = cache.get(key); @@ -136,12 +132,8 @@ public final class InfoCache { final private long expireTimestamp; final private Info info; - private CacheData(@NonNull final Info info, - final long timeout, - @NonNull final TimeUnit timeUnit) { - this.expireTimestamp = System.currentTimeMillis() + - TimeUnit.MILLISECONDS.convert(timeout, timeUnit); - + private CacheData(@NonNull final Info info, final long timeoutMillis) { + this.expireTimestamp = System.currentTimeMillis() + timeoutMillis; this.info = info; } diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.java b/app/src/main/java/org/schabi/newpipe/util/Localization.java index 43ebc1677..c1e5c9ed4 100644 --- a/app/src/main/java/org/schabi/newpipe/util/Localization.java +++ b/app/src/main/java/org/schabi/newpipe/util/Localization.java @@ -4,6 +4,7 @@ import android.content.Context; import android.content.SharedPreferences; import android.content.res.Resources; import android.preference.PreferenceManager; +import android.support.annotation.NonNull; import android.support.annotation.PluralsRes; import android.support.annotation.StringRes; import android.text.TextUtils; @@ -14,7 +15,9 @@ import java.text.DateFormat; import java.text.NumberFormat; import java.text.ParseException; import java.text.SimpleDateFormat; +import java.util.Arrays; import java.util.Date; +import java.util.List; import java.util.Locale; /* @@ -39,9 +42,33 @@ import java.util.Locale; public class Localization { + public final static String DOT_SEPARATOR = " • "; + private Localization() { } + @NonNull + public static String concatenateStrings(final String... strings) { + return concatenateStrings(Arrays.asList(strings)); + } + + @NonNull + public static String concatenateStrings(final List strings) { + if (strings.isEmpty()) return ""; + + final StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append(strings.get(0)); + + for (int i = 1; i < strings.size(); i++) { + final String string = strings.get(i); + if (!TextUtils.isEmpty(string)) { + stringBuilder.append(DOT_SEPARATOR).append(strings.get(i)); + } + } + + return stringBuilder.toString(); + } + public static Locale getPreferredLocale(Context context) { SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index 8894af9df..26088a64c 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -1,5 +1,6 @@ package org.schabi.newpipe.util; +import android.annotation.SuppressLint; import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.Context; @@ -7,8 +8,11 @@ import android.content.Intent; import android.net.Uri; import android.os.Build; import android.preference.PreferenceManager; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentManager; +import android.support.v4.app.FragmentTransaction; import android.support.v7.app.AlertDialog; import android.util.Log; import android.widget.Toast; @@ -20,7 +24,6 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.about.AboutActivity; import org.schabi.newpipe.download.DownloadActivity; import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.stream.AudioStream; @@ -34,6 +37,10 @@ import org.schabi.newpipe.fragments.list.feed.FeedFragment; import org.schabi.newpipe.fragments.list.kiosk.KioskFragment; import org.schabi.newpipe.fragments.list.playlist.PlaylistFragment; import org.schabi.newpipe.fragments.list.search.SearchFragment; +import org.schabi.newpipe.fragments.local.bookmark.LastPlayedFragment; +import org.schabi.newpipe.fragments.local.bookmark.LocalPlaylistFragment; +import org.schabi.newpipe.fragments.local.bookmark.MostPlayedFragment; +import org.schabi.newpipe.fragments.subscription.SubscriptionsImportFragment; import org.schabi.newpipe.history.HistoryActivity; import org.schabi.newpipe.player.BackgroundPlayer; import org.schabi.newpipe.player.BackgroundPlayerActivity; @@ -57,39 +64,45 @@ public class NavigationHelper { // Players //////////////////////////////////////////////////////////////////////////*/ - public static Intent getPlayerIntent(final Context context, - final Class targetClazz, - final PlayQueue playQueue, - final String quality) { - Intent intent = new Intent(context, targetClazz) - .putExtra(VideoPlayer.PLAY_QUEUE, playQueue); + @NonNull + public static Intent getPlayerIntent(@NonNull final Context context, + @NonNull final Class targetClazz, + @NonNull final PlayQueue playQueue, + @Nullable final String quality) { + Intent intent = new Intent(context, targetClazz); + + final String cacheKey = SerializedCache.getInstance().put(playQueue, PlayQueue.class); + if (cacheKey != null) intent.putExtra(VideoPlayer.PLAY_QUEUE_KEY, cacheKey); if (quality != null) intent.putExtra(VideoPlayer.PLAYBACK_QUALITY, quality); return intent; } - public static Intent getPlayerIntent(final Context context, - final Class targetClazz, - final PlayQueue playQueue) { + @NonNull + public static Intent getPlayerIntent(@NonNull final Context context, + @NonNull final Class targetClazz, + @NonNull final PlayQueue playQueue) { return getPlayerIntent(context, targetClazz, playQueue, null); } - public static Intent getPlayerEnqueueIntent(final Context context, - final Class targetClazz, - final PlayQueue playQueue, + @NonNull + public static Intent getPlayerEnqueueIntent(@NonNull final Context context, + @NonNull final Class targetClazz, + @NonNull final PlayQueue playQueue, final boolean selectOnAppend) { return getPlayerIntent(context, targetClazz, playQueue) .putExtra(BasePlayer.APPEND_ONLY, true) .putExtra(BasePlayer.SELECT_ON_APPEND, selectOnAppend); } - public static Intent getPlayerIntent(final Context context, - final Class targetClazz, - final PlayQueue playQueue, + @NonNull + public static Intent getPlayerIntent(@NonNull final Context context, + @NonNull final Class targetClazz, + @NonNull final PlayQueue playQueue, final int repeatMode, final float playbackSpeed, final float playbackPitch, - final String playbackQuality) { + @Nullable final String playbackQuality) { return getPlayerIntent(context, targetClazz, playQueue, playbackQuality) .putExtra(BasePlayer.REPEAT_MODE, repeatMode) .putExtra(BasePlayer.PLAYBACK_SPEED, playbackSpeed) @@ -129,12 +142,12 @@ public class NavigationHelper { } Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show(); - context.startService(getPlayerIntent(context, PopupVideoPlayer.class, queue)); + startService(context, getPlayerIntent(context, PopupVideoPlayer.class, queue)); } public static void playOnBackgroundPlayer(final Context context, final PlayQueue queue) { Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT).show(); - context.startService(getPlayerIntent(context, BackgroundPlayer.class, queue)); + startService(context, getPlayerIntent(context, BackgroundPlayer.class, queue)); } public static void enqueueOnPopupPlayer(final Context context, final PlayQueue queue) { @@ -148,7 +161,8 @@ public class NavigationHelper { } Toast.makeText(context, R.string.popup_playing_append, Toast.LENGTH_SHORT).show(); - context.startService(getPlayerEnqueueIntent(context, PopupVideoPlayer.class, queue, selectOnAppend)); + startService(context, + getPlayerEnqueueIntent(context, PopupVideoPlayer.class, queue, selectOnAppend)); } public static void enqueueOnBackgroundPlayer(final Context context, final PlayQueue queue) { @@ -157,7 +171,16 @@ public class NavigationHelper { public static void enqueueOnBackgroundPlayer(final Context context, final PlayQueue queue, boolean selectOnAppend) { Toast.makeText(context, R.string.background_player_append, Toast.LENGTH_SHORT).show(); - context.startService(getPlayerEnqueueIntent(context, BackgroundPlayer.class, queue, selectOnAppend)); + startService(context, + getPlayerEnqueueIntent(context, BackgroundPlayer.class, queue, selectOnAppend)); + } + + public static void startService(@NonNull final Context context, @NonNull final Intent intent) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent); + } else { + context.startService(intent); + } } /*////////////////////////////////////////////////////////////////////////// @@ -227,6 +250,12 @@ public class NavigationHelper { // Through FragmentManager //////////////////////////////////////////////////////////////////////////*/ + @SuppressLint("CommitTransaction") + private static FragmentTransaction defaultTransaction(FragmentManager fragmentManager) { + return fragmentManager.beginTransaction() + .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out); + } + public static void gotoMainFragment(FragmentManager fragmentManager) { ImageLoader.getInstance().clearMemoryCache(); @@ -238,8 +267,7 @@ public class NavigationHelper { InfoCache.getInstance().trimCache(); fragmentManager.popBackStackImmediate(null, FragmentManager.POP_BACK_STACK_INCLUSIVE); - fragmentManager.beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, new MainFragment()) .addToBackStack(MAIN_FRAGMENT_TAG) .commit(); @@ -256,8 +284,7 @@ public class NavigationHelper { } public static void openSearchFragment(FragmentManager fragmentManager, int serviceId, String query) { - fragmentManager.beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, SearchFragment.getInstance(serviceId, query)) .addToBackStack(SEARCH_FRAGMENT_TAG) .commit(); @@ -281,8 +308,7 @@ public class NavigationHelper { VideoDetailFragment instance = VideoDetailFragment.getInstance(serviceId, url, title); instance.setAutoplay(autoPlay); - fragmentManager.beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, instance) .addToBackStack(null) .commit(); @@ -290,8 +316,7 @@ public class NavigationHelper { public static void openChannelFragment(FragmentManager fragmentManager, int serviceId, String url, String name) { if (name == null) name = ""; - fragmentManager.beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, ChannelFragment.getInstance(serviceId, url, name)) .addToBackStack(null) .commit(); @@ -299,30 +324,55 @@ public class NavigationHelper { public static void openPlaylistFragment(FragmentManager fragmentManager, int serviceId, String url, String name) { if (name == null) name = ""; - fragmentManager.beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, PlaylistFragment.getInstance(serviceId, url, name)) .addToBackStack(null) .commit(); } public static void openWhatsNewFragment(FragmentManager fragmentManager) { - fragmentManager.beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, new FeedFragment()) .addToBackStack(null) .commit(); } - public static void openKioskFragment(FragmentManager fragmentManager, int serviceId, String kioskId) - throws ExtractionException { - fragmentManager.beginTransaction() - .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + public static void openKioskFragment(FragmentManager fragmentManager, int serviceId, String kioskId) throws ExtractionException { + defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, KioskFragment.getInstance(serviceId, kioskId)) .addToBackStack(null) .commit(); } + public static void openLocalPlaylistFragment(FragmentManager fragmentManager, long playlistId, String name) { + if (name == null) name = ""; + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, LocalPlaylistFragment.getInstance(playlistId, name)) + .addToBackStack(null) + .commit(); + } + + public static void openLastPlayedFragment(FragmentManager fragmentManager) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, new LastPlayedFragment()) + .addToBackStack(null) + .commit(); + } + + public static void openMostPlayedFragment(FragmentManager fragmentManager) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, new MostPlayedFragment()) + .addToBackStack(null) + .commit(); + } + + public static void openSubscriptionsImportFragment(FragmentManager fragmentManager, int serviceId) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, SubscriptionsImportFragment.getInstance(serviceId)) + .addToBackStack(null) + .commit(); + } + /*////////////////////////////////////////////////////////////////////////// // Through Intents //////////////////////////////////////////////////////////////////////////*/ @@ -419,10 +469,6 @@ public class NavigationHelper { } public static Intent getIntentByLink(Context context, StreamingService service, String url) throws ExtractionException { - if (service != ServiceList.YouTube.getService()) { - throw new ExtractionException("Service not supported at the moment"); - } - StreamingService.LinkType linkType = service.getLinkTypeByUrl(url); if (linkType == StreamingService.LinkType.NONE) { diff --git a/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.java b/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.java new file mode 100644 index 000000000..01416b279 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.java @@ -0,0 +1,16 @@ +package org.schabi.newpipe.util; + +import android.support.v7.widget.RecyclerView; + +public abstract class OnClickGesture { + + public abstract void selected(T selectedItem); + + public void held(T selectedItem) { + // Optional gesture + } + + public void drag(T selectedItem, RecyclerView.ViewHolder viewHolder) { + // Optional gesture + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/PopupMenuIconHacker.java b/app/src/main/java/org/schabi/newpipe/util/PopupMenuIconHacker.java deleted file mode 100644 index 70affb900..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/PopupMenuIconHacker.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.schabi.newpipe.util; - -import android.widget.PopupMenu; - -import java.lang.reflect.Field; -import java.lang.reflect.Method; - -/** - * Created by Christian Schabesberger on 20.01.18. - * Copyright 2018 Christian Schabesberger - * PopupMenuIconHacker.java is part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -public class PopupMenuIconHacker { - public static void setShowPopupIcon(PopupMenu menu) throws Exception { - try { - Field[] fields = menu.getClass().getDeclaredFields(); - for (Field field : fields) { - if ("mPopup".equals(field.getName())) { - field.setAccessible(true); - Object menuPopupHelper = field.get(menu); - Class classPopupHelper = Class.forName(menuPopupHelper - .getClass().getName()); - Method setForceIcons = classPopupHelper.getMethod( - "setForceShowIcon", boolean.class); - setForceIcons.invoke(menuPopupHelper, true); - break; - } - } - } catch (Exception e) { - throw new Exception("Could not make Popup menu show Icons", e); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/SerializedCache.java b/app/src/main/java/org/schabi/newpipe/util/SerializedCache.java new file mode 100644 index 000000000..02871aff5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/SerializedCache.java @@ -0,0 +1,112 @@ +package org.schabi.newpipe.util; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.util.LruCache; +import android.util.Log; + +import org.schabi.newpipe.MainActivity; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.UUID; + +public class SerializedCache { + private static final boolean DEBUG = MainActivity.DEBUG; + private final String TAG = getClass().getSimpleName(); + + private static final SerializedCache instance = new SerializedCache(); + private static final int MAX_ITEMS_ON_CACHE = 5; + + private static final LruCache lruCache = + new LruCache<>(MAX_ITEMS_ON_CACHE); + + private SerializedCache() { + //no instance + } + + public static SerializedCache getInstance() { + return instance; + } + + @Nullable + public T take(@NonNull final String key, @NonNull final Class type) { + if (DEBUG) Log.d(TAG, "take() called with: key = [" + key + "]"); + synchronized (lruCache) { + return lruCache.get(key) != null ? getItem(lruCache.remove(key), type) : null; + } + } + + @Nullable + public T get(@NonNull final String key, @NonNull final Class type) { + if (DEBUG) Log.d(TAG, "get() called with: key = [" + key + "]"); + synchronized (lruCache) { + final CacheData data = lruCache.get(key); + return data != null ? getItem(data, type) : null; + } + } + + @Nullable + public String put(@NonNull T item, @NonNull final Class type) { + final String key = UUID.randomUUID().toString(); + return put(key, item, type) ? key : null; + } + + public boolean put(@NonNull final String key, @NonNull T item, + @NonNull final Class type) { + if (DEBUG) Log.d(TAG, "put() called with: key = [" + key + "], item = [" + item + "]"); + synchronized (lruCache) { + try { + lruCache.put(key, new CacheData<>(clone(item, type), type)); + return true; + } catch (final Exception error) { + Log.e(TAG, "Serialization failed for: ", error); + } + } + return false; + } + + public void clear() { + if (DEBUG) Log.d(TAG, "clear() called"); + synchronized (lruCache) { + lruCache.evictAll(); + } + } + + public long size() { + synchronized (lruCache) { + return lruCache.size(); + } + } + + @Nullable + private T getItem(@NonNull final CacheData data, @NonNull final Class type) { + return type.isAssignableFrom(data.type) ? type.cast(data.item) : null; + } + + @NonNull + private T clone(@NonNull T item, + @NonNull final Class type) throws Exception { + final ByteArrayOutputStream bytesOutput = new ByteArrayOutputStream(); + try (final ObjectOutputStream objectOutput = new ObjectOutputStream(bytesOutput)) { + objectOutput.writeObject(item); + objectOutput.flush(); + } + final Object clone = new ObjectInputStream( + new ByteArrayInputStream(bytesOutput.toByteArray())).readObject(); + return type.cast(clone); + } + + final private static class CacheData { + private final T item; + private final Class type; + + private CacheData(@NonNull final T item, @NonNull Class type) { + this.item = item; + this.type = type; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java index ce1491ba4..9d71ae83a 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java @@ -3,6 +3,7 @@ package org.schabi.newpipe.util; import android.content.Context; import android.preference.PreferenceManager; import android.support.annotation.DrawableRes; +import android.support.annotation.StringRes; import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.R; @@ -11,8 +12,12 @@ import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import java.util.concurrent.TimeUnit; + +import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; + public class ServiceHelper { - private static final StreamingService DEFAULT_FALLBACK_SERVICE = ServiceList.YouTube.getService(); + private static final StreamingService DEFAULT_FALLBACK_SERVICE = ServiceList.YouTube; @DrawableRes public static int getIcon(int serviceId) { @@ -26,6 +31,39 @@ public class ServiceHelper { } } + /** + * Get a resource string with instructions for importing subscriptions for each service. + * + * @return the string resource containing the instructions or -1 if the service don't support it + */ + @StringRes + public static int getImportInstructions(int serviceId) { + switch (serviceId) { + case 0: + return R.string.import_youtube_instructions; + case 1: + return R.string.import_soundcloud_instructions; + default: + return -1; + } + } + + /** + * For services that support importing from a channel url, return a hint that will + * be used in the EditText that the user will type in his channel url. + * + * @return the hint's string resource or -1 if the service don't support it + */ + @StringRes + public static int getImportInstructionsHint(int serviceId) { + switch (serviceId) { + case 1: + return R.string.import_soundcloud_instructions_hint; + default: + return -1; + } + } + public static int getSelectedServiceId(Context context) { if (BuildConfig.BUILD_TYPE.equals("release")) return DEFAULT_FALLBACK_SERVICE.getServiceId(); @@ -45,9 +83,9 @@ public class ServiceHelper { public static void setSelectedServiceId(Context context, int serviceId) { String serviceName; try { - serviceName = NewPipe.getService(serviceId).getServiceInfo().name; + serviceName = NewPipe.getService(serviceId).getServiceInfo().getName(); } catch (ExtractionException e) { - serviceName = DEFAULT_FALLBACK_SERVICE.getServiceInfo().name; + serviceName = DEFAULT_FALLBACK_SERVICE.getServiceInfo().getName(); } setSelectedServicePreferences(context, serviceName); @@ -55,7 +93,7 @@ public class ServiceHelper { public static void setSelectedServiceId(Context context, String serviceName) { int serviceId = NewPipe.getIdOfService(serviceName); - if (serviceId == -1) serviceName = DEFAULT_FALLBACK_SERVICE.getServiceInfo().name; + if (serviceId == -1) serviceName = DEFAULT_FALLBACK_SERVICE.getServiceInfo().getName(); setSelectedServicePreferences(context, serviceName); } @@ -64,4 +102,12 @@ public class ServiceHelper { PreferenceManager.getDefaultSharedPreferences(context).edit(). putString(context.getString(R.string.current_service_key), serviceName).apply(); } + + public static long getCacheExpirationMillis(final int serviceId) { + if (serviceId == SoundCloud.getServiceId()) { + return TimeUnit.MILLISECONDS.convert(5, TimeUnit.MINUTES); + } else { + return TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS); + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/util/SliderStrategy.java b/app/src/main/java/org/schabi/newpipe/util/SliderStrategy.java new file mode 100644 index 000000000..efec1abb0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/SliderStrategy.java @@ -0,0 +1,73 @@ +package org.schabi.newpipe.util; + +public interface SliderStrategy { + /** + * Converts from zeroed double with a minimum offset to the nearest rounded slider + * equivalent integer + * */ + int progressOf(final double value); + + /** + * Converts from slider integer value to an equivalent double value with a given + * minimum offset + * */ + double valueOf(final int progress); + + // TODO: also implement linear strategy when needed + + final class Quadratic implements SliderStrategy { + private final double leftGap; + private final double rightGap; + private final double center; + + private final int centerProgress; + + /** + * Quadratic slider strategy that scales the value of a slider given how far the slider + * progress is from the center of the slider. The further away from the center, + * the faster the interpreted value changes, and vice versa. + * + * @param minimum the minimum value of the interpreted value of the slider. + * @param maximum the maximum value of the interpreted value of the slider. + * @param center center of the interpreted value between the minimum and maximum, which + * will be used as the center value on the slider progress. Doesn't need + * to be the average of the minimum and maximum values, but must be in + * between the two. + * @param maxProgress the maximum possible progress of the slider, this is the + * value that is shown for the UI and controls the granularity of + * the slider. Should be as large as possible to avoid floating + * point round-off error. Using odd number is recommended. + * */ + public Quadratic(double minimum, double maximum, double center, int maxProgress) { + if (center < minimum || center > maximum) { + throw new IllegalArgumentException("Center must be in between minimum and maximum"); + } + + this.leftGap = minimum - center; + this.rightGap = maximum - center; + this.center = center; + + this.centerProgress = maxProgress / 2; + } + + @Override + public int progressOf(double value) { + final double difference = value - center; + final double root = difference >= 0 ? + Math.sqrt(difference / rightGap) : + -Math.sqrt(Math.abs(difference / leftGap)); + final double offset = Math.round(root * centerProgress); + + return (int) (centerProgress + offset); + } + + @Override + public double valueOf(int progress) { + final int offset = progress - centerProgress; + final double square = Math.pow(((double) offset) / ((double) centerProgress), 2); + final double difference = square * (offset >= 0 ? rightGap : leftGap); + + return difference + center; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java index b0e00465a..1edc4dfec 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java @@ -1,3 +1,22 @@ +/* + * Copyright 2018 Mauricio Colli + * ThemeHelper.java is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.schabi.newpipe.util; import android.content.Context; @@ -5,6 +24,9 @@ import android.content.res.TypedArray; import android.preference.PreferenceManager; import android.support.annotation.AttrRes; import android.support.annotation.StyleRes; +import android.support.v4.content.ContextCompat; +import android.util.TypedValue; +import android.view.ContextThemeWrapper; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.NewPipe; @@ -41,16 +63,57 @@ public class ThemeHelper { * @param context context to get the preference */ public static boolean isLightThemeSelected(Context context) { - return getSelectedTheme(context).equals(context.getResources().getString(R.string.light_theme_key)); + return getSelectedThemeString(context).equals(context.getResources().getString(R.string.light_theme_key)); } + + /** + * Create and return a wrapped context with the default selected theme set. + * + * @param baseContext the base context for the wrapper + * @return a wrapped-styled context + */ + public static Context getThemedContext(Context baseContext) { + return new ContextThemeWrapper(baseContext, getThemeForService(baseContext, -1)); + } + + /** + * Return the selected theme without being styled to any service (see {@link #getThemeForService(Context, int)}). + * + * @param context context to get the selected theme + * @return the selected style (the default one) + */ + @StyleRes + public static int getDefaultTheme(Context context) { + return getThemeForService(context, -1); + } + + /** + * Return a dialog theme styled according to the (default) selected theme. + * + * @param context context to get the selected theme + * @return the dialog style (the default one) + */ + @StyleRes + public static int getDialogTheme(Context context) { + return isLightThemeSelected(context) ? R.style.LightDialogTheme : R.style.DarkDialogTheme; + } + + /** + * Return the selected theme styled according to the serviceId. + * + * @param context context to get the selected theme + * @param serviceId return a theme styled to this service, + * -1 to get the default + * @return the selected style (styled) + */ @StyleRes public static int getThemeForService(Context context, int serviceId) { String lightTheme = context.getResources().getString(R.string.light_theme_key); String darkTheme = context.getResources().getString(R.string.dark_theme_key); String blackTheme = context.getResources().getString(R.string.black_theme_key); - String selectedTheme = getSelectedTheme(context); + String selectedTheme = getSelectedThemeString(context); int defaultTheme = R.style.DarkTheme; if (selectedTheme.equals(lightTheme)) defaultTheme = R.style.LightTheme; @@ -73,7 +136,7 @@ public class ThemeHelper { else if (selectedTheme.equals(blackTheme)) themeName = "BlackTheme"; else if (selectedTheme.equals(darkTheme)) themeName = "DarkTheme"; - themeName += "." + service.getServiceInfo().name; + themeName += "." + service.getServiceInfo().getName(); int resourceId = context.getResources().getIdentifier(themeName, "style", context.getPackageName()); if (resourceId > 0) { @@ -83,19 +146,13 @@ public class ThemeHelper { return defaultTheme; } - public static String getSelectedTheme(Context context) { - String themeKey = context.getString(R.string.theme_key); - String defaultTheme = context.getResources().getString(R.string.default_theme_value); - return PreferenceManager.getDefaultSharedPreferences(context).getString(themeKey, defaultTheme); - } - @StyleRes public static int getSettingsThemeStyle(Context context) { String lightTheme = context.getResources().getString(R.string.light_theme_key); String darkTheme = context.getResources().getString(R.string.dark_theme_key); String blackTheme = context.getResources().getString(R.string.black_theme_key); - String selectedTheme = getSelectedTheme(context); + String selectedTheme = getSelectedThemeString(context); if (selectedTheme.equals(lightTheme)) return R.style.LightSettingsTheme; else if (selectedTheme.equals(blackTheme)) return R.style.BlackSettingsTheme; @@ -113,4 +170,24 @@ public class ThemeHelper { a.recycle(); return attributeResourceId; } + + /** + * Get a color from an attr styled according to the the context's theme. + */ + public static int resolveColorFromAttr(Context context, @AttrRes int attrColor) { + final TypedValue value = new TypedValue(); + context.getTheme().resolveAttribute(attrColor, value, true); + + if (value.resourceId != 0) { + return ContextCompat.getColor(context, value.resourceId); + } + + return value.data; + } + + private static String getSelectedThemeString(Context context) { + String themeKey = context.getString(R.string.theme_key); + String defaultTheme = context.getResources().getString(R.string.default_theme_value); + return PreferenceManager.getDefaultSharedPreferences(context).getString(themeKey, defaultTheme); + } } diff --git a/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java b/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java new file mode 100644 index 000000000..adef7e76f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java @@ -0,0 +1,230 @@ +/* + * Copyright 2018 Mauricio Colli + * CollapsibleView.java is part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.schabi.newpipe.views; + +import android.animation.ValueAnimator; +import android.content.Context; +import android.os.Build; +import android.os.Parcelable; +import android.support.annotation.IntDef; +import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; +import android.util.AttributeSet; +import android.util.Log; +import android.widget.LinearLayout; + +import org.schabi.newpipe.util.AnimationUtils; + +import java.lang.annotation.Retention; +import java.util.ArrayList; +import java.util.List; + +import icepick.Icepick; +import icepick.State; + +import static java.lang.annotation.RetentionPolicy.SOURCE; +import static org.schabi.newpipe.MainActivity.DEBUG; + +/** + * A view that can be fully collapsed and expanded. + */ +public class CollapsibleView extends LinearLayout { + private static final String TAG = CollapsibleView.class.getSimpleName(); + + public CollapsibleView(Context context) { + super(context); + } + + public CollapsibleView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public CollapsibleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public CollapsibleView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + /*////////////////////////////////////////////////////////////////////////// + // Collapse/expand logic + //////////////////////////////////////////////////////////////////////////*/ + + private static final int ANIMATION_DURATION = 420; + public static final int COLLAPSED = 0, EXPANDED = 1; + + @Retention(SOURCE) + @IntDef({COLLAPSED, EXPANDED}) + public @interface ViewMode {} + + @State @ViewMode int currentState = COLLAPSED; + private boolean readyToChangeState; + + private int targetHeight = -1; + private ValueAnimator currentAnimator; + private List listeners = new ArrayList<>(); + + /** + * This method recalculates the height of this view so it must be called when + * some child changes (e.g. add new views, change text). + */ + public void ready() { + if (DEBUG) { + Log.d(TAG, getDebugLogString("ready() called")); + } + + measure(MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.AT_MOST), MeasureSpec.UNSPECIFIED); + targetHeight = getMeasuredHeight(); + + getLayoutParams().height = currentState == COLLAPSED ? 0 : targetHeight; + requestLayout(); + broadcastState(); + + readyToChangeState = true; + + if (DEBUG) { + Log.d(TAG, getDebugLogString("ready() *after* measuring")); + } + } + + public void collapse() { + if (DEBUG) { + Log.d(TAG, getDebugLogString("collapse() called")); + } + + if (!readyToChangeState) return; + + final int height = getHeight(); + if (height == 0) { + setCurrentState(COLLAPSED); + return; + } + + if (currentAnimator != null && currentAnimator.isRunning()) currentAnimator.cancel(); + currentAnimator = AnimationUtils.animateHeight(this, ANIMATION_DURATION, 0); + + setCurrentState(COLLAPSED); + } + + public void expand() { + if (DEBUG) { + Log.d(TAG, getDebugLogString("expand() called")); + } + + if (!readyToChangeState) return; + + final int height = getHeight(); + if (height == this.targetHeight) { + setCurrentState(EXPANDED); + return; + } + + if (currentAnimator != null && currentAnimator.isRunning()) currentAnimator.cancel(); + currentAnimator = AnimationUtils.animateHeight(this, ANIMATION_DURATION, this.targetHeight); + setCurrentState(EXPANDED); + } + + public void switchState() { + if (!readyToChangeState) return; + + if (currentState == COLLAPSED) { + expand(); + } else { + collapse(); + } + } + + @ViewMode + public int getCurrentState() { + return currentState; + } + + public void setCurrentState(@ViewMode int currentState) { + this.currentState = currentState; + broadcastState(); + } + + public void broadcastState() { + for (StateListener listener : listeners) { + listener.onStateChanged(currentState); + } + } + + /** + * Add a listener which will be listening for changes in this view (i.e. collapsed or expanded). + */ + public void addListener(final StateListener listener) { + if (listeners.contains(listener)) { + throw new IllegalStateException("Trying to add the same listener multiple times"); + } + + listeners.add(listener); + } + + /** + * Remove a listener so it doesn't receive more state changes. + */ + public void removeListener(final StateListener listener) { + listeners.remove(listener); + } + + /** + * Simple interface used for listening state changes of the {@link CollapsibleView}. + */ + public interface StateListener { + /** + * Called when the state changes. + * + * @param newState the state that the {@link CollapsibleView} transitioned to,
+ * it's an integer being either {@link #COLLAPSED} or {@link #EXPANDED} + */ + void onStateChanged(@ViewMode int newState); + } + + /*////////////////////////////////////////////////////////////////////////// + // State Saving + //////////////////////////////////////////////////////////////////////////*/ + + @Nullable + @Override + public Parcelable onSaveInstanceState() { + return Icepick.saveInstanceState(this, super.onSaveInstanceState()); + } + + @Override + public void onRestoreInstanceState(Parcelable state) { + super.onRestoreInstanceState(Icepick.restoreInstanceState(this, state)); + + ready(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Internal + //////////////////////////////////////////////////////////////////////////*/ + + public String getDebugLogString(String description) { + return String.format("%-100s → %s", + description, "readyToChangeState = [" + readyToChangeState + "], currentState = [" + currentState + "], targetHeight = [" + targetHeight + "]," + + " mW x mH = [" + getMeasuredWidth() + "x" + getMeasuredHeight() + "]" + + " W x H = [" + getWidth() + "x" + getHeight() + "]"); + } +} diff --git a/app/src/main/res/drawable-hdpi/ic_backup_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_backup_black_24dp.png new file mode 100644 index 000000000..e0938f1dc Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_backup_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_backup_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_backup_white_24dp.png new file mode 100644 index 000000000..5e0b464cf Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_backup_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_bookmark_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_bookmark_black_24dp.png new file mode 100644 index 000000000..7ad39da3a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_bookmark_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_bookmark_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_bookmark_white_24dp.png new file mode 100644 index 000000000..9de15c51a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_bookmark_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_bug_report_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_bug_report_black_24dp.png new file mode 100644 index 000000000..1bccb1d11 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_bug_report_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_bug_report_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_bug_report_white_24dp.png new file mode 100644 index 000000000..0c963e1ca Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_bug_report_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_import_export_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_import_export_black_24dp.png new file mode 100644 index 000000000..b4466c849 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_import_export_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_import_export_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_import_export_white_24dp.png new file mode 100644 index 000000000..5b6c02010 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_import_export_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_info_outline_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_info_outline_black_24dp.png new file mode 100644 index 000000000..4b5ab06e1 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_info_outline_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_info_outline_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_info_outline_white_24dp.png new file mode 100644 index 000000000..c7b1113cf Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_info_outline_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_playlist_add_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_playlist_add_black_24dp.png new file mode 100644 index 000000000..731b42590 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_playlist_add_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_playlist_add_check_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_playlist_add_check_black_24dp.png new file mode 100644 index 000000000..92448842b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_playlist_add_check_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_playlist_add_check_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_playlist_add_check_white_24dp.png new file mode 100644 index 000000000..bd23b9c48 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_playlist_add_check_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_playlist_add_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_playlist_add_white_24dp.png new file mode 100644 index 000000000..4fb76e178 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_playlist_add_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_save_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_save_black_24dp.png new file mode 100644 index 000000000..b959dc4a8 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_save_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_save_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_save_white_24dp.png new file mode 100644 index 000000000..dd3f10664 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_save_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_settings_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_settings_black_24dp.png new file mode 100644 index 000000000..acf1ddf85 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_settings_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_settings_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_settings_white_24dp.png new file mode 100644 index 000000000..97ded33b5 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_settings_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_backup_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_backup_black_24dp.png new file mode 100644 index 000000000..4cd6741c0 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_backup_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_backup_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_backup_white_24dp.png new file mode 100644 index 000000000..aa640629a Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_backup_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_bookmark_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_bookmark_black_24dp.png new file mode 100644 index 000000000..0a10c2494 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_bookmark_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_bookmark_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_bookmark_white_24dp.png new file mode 100644 index 000000000..84f16627d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_bookmark_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_bug_report_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_bug_report_black_24dp.png new file mode 100644 index 000000000..58aef662d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_bug_report_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_bug_report_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_bug_report_white_24dp.png new file mode 100644 index 000000000..86e15f0d7 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_bug_report_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_import_export_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_import_export_black_24dp.png new file mode 100644 index 000000000..90f8c4567 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_import_export_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_import_export_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_import_export_white_24dp.png new file mode 100644 index 000000000..151188cf8 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_import_export_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_info_outline_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_info_outline_black_24dp.png new file mode 100644 index 000000000..e0c9fe0eb Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_info_outline_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_info_outline_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_info_outline_white_24dp.png new file mode 100644 index 000000000..353e06495 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_info_outline_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_playlist_add_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_playlist_add_black_24dp.png new file mode 100644 index 000000000..d7a7514a8 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_playlist_add_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_playlist_add_check_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_playlist_add_check_black_24dp.png new file mode 100644 index 000000000..416490774 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_playlist_add_check_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_playlist_add_check_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_playlist_add_check_white_24dp.png new file mode 100644 index 000000000..0e35fe739 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_playlist_add_check_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_playlist_add_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_playlist_add_white_24dp.png new file mode 100644 index 000000000..73c981285 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_playlist_add_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_save_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_save_black_24dp.png new file mode 100644 index 000000000..663479b73 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_save_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_save_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_save_white_24dp.png new file mode 100644 index 000000000..015062ed3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_save_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_settings_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_settings_black_24dp.png new file mode 100644 index 000000000..c59419c02 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_settings_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_settings_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_settings_white_24dp.png new file mode 100644 index 000000000..8909c3553 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_settings_white_24dp.png differ diff --git a/app/src/main/res/drawable-nodpi/background_header.png b/app/src/main/res/drawable-nodpi/background_header.png new file mode 100644 index 000000000..b417038f6 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/background_header.png differ diff --git a/app/src/main/res/drawable-nodpi/np_logo_nude_shadow.png b/app/src/main/res/drawable-nodpi/np_logo_nude_shadow.png new file mode 100644 index 000000000..99b91d374 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/np_logo_nude_shadow.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_backup_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_backup_black_24dp.png new file mode 100644 index 000000000..81155da52 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_backup_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_backup_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_backup_white_24dp.png new file mode 100644 index 000000000..a9602d11b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_backup_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_bookmark_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_bookmark_black_24dp.png new file mode 100644 index 000000000..5d71bf213 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_bookmark_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_bookmark_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_bookmark_white_24dp.png new file mode 100644 index 000000000..872349cca Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_bookmark_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_bug_report_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_bug_report_black_24dp.png new file mode 100644 index 000000000..107f74a20 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_bug_report_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_bug_report_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_bug_report_white_24dp.png new file mode 100644 index 000000000..36b826bb8 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_bug_report_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_import_export_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_import_export_black_24dp.png new file mode 100644 index 000000000..9b643bd3b Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_import_export_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_import_export_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_import_export_white_24dp.png new file mode 100644 index 000000000..e22e18866 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_import_export_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_info_outline_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_info_outline_black_24dp.png new file mode 100644 index 000000000..b706f0d06 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_info_outline_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_info_outline_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_info_outline_white_24dp.png new file mode 100644 index 000000000..c571b2e3e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_info_outline_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_playlist_add_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_playlist_add_black_24dp.png new file mode 100644 index 000000000..dc4ebe9f3 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_playlist_add_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_playlist_add_check_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_playlist_add_check_black_24dp.png new file mode 100644 index 000000000..24855e94f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_playlist_add_check_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_playlist_add_check_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_playlist_add_check_white_24dp.png new file mode 100644 index 000000000..a94c5d035 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_playlist_add_check_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_playlist_add_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_playlist_add_white_24dp.png new file mode 100644 index 000000000..52ccba0b2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_playlist_add_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_save_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_save_black_24dp.png new file mode 100644 index 000000000..eca2d92ec Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_save_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_save_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_save_white_24dp.png new file mode 100644 index 000000000..adda09575 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_save_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_settings_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_settings_black_24dp.png new file mode 100644 index 000000000..e84e188a1 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_settings_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_settings_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_settings_white_24dp.png new file mode 100644 index 000000000..5caedc8e5 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_settings_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_backup_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_backup_black_24dp.png new file mode 100644 index 000000000..6506c7236 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_backup_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_backup_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_backup_white_24dp.png new file mode 100644 index 000000000..3ff57ad3e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_backup_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_bookmark_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_bookmark_black_24dp.png new file mode 100644 index 000000000..2189be346 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_bookmark_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_bookmark_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_bookmark_white_24dp.png new file mode 100644 index 000000000..3faff90bb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_bookmark_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_bug_report_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_bug_report_black_24dp.png new file mode 100644 index 000000000..af8c82e6e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_bug_report_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_bug_report_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_bug_report_white_24dp.png new file mode 100644 index 000000000..766bac447 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_bug_report_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_import_export_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_import_export_black_24dp.png new file mode 100644 index 000000000..78e865dfa Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_import_export_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_import_export_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_import_export_white_24dp.png new file mode 100644 index 000000000..33c21c5c4 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_import_export_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_info_outline_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_info_outline_black_24dp.png new file mode 100644 index 000000000..3847a9fe7 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_info_outline_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_info_outline_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_info_outline_white_24dp.png new file mode 100644 index 000000000..c41a5fcff Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_info_outline_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_playlist_add_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_playlist_add_black_24dp.png new file mode 100644 index 000000000..af0bae3f0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_playlist_add_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_playlist_add_check_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_playlist_add_check_black_24dp.png new file mode 100644 index 000000000..ac03e19ab Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_playlist_add_check_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_playlist_add_check_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_playlist_add_check_white_24dp.png new file mode 100644 index 000000000..290088718 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_playlist_add_check_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_playlist_add_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_playlist_add_white_24dp.png new file mode 100644 index 000000000..3f652366d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_playlist_add_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_save_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_save_black_24dp.png new file mode 100644 index 000000000..871291b4e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_save_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_save_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_save_white_24dp.png new file mode 100644 index 000000000..3e0ce1a5f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_save_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_settings_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_settings_black_24dp.png new file mode 100644 index 000000000..3023ff8da Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_settings_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_settings_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_settings_white_24dp.png new file mode 100644 index 000000000..eabb0a2ba Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_settings_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_backup_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_backup_black_24dp.png new file mode 100644 index 000000000..248289e97 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_backup_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_backup_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_backup_white_24dp.png new file mode 100644 index 000000000..2180f73e8 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_backup_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_bookmark_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_bookmark_black_24dp.png new file mode 100644 index 000000000..2b90acd74 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_bookmark_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_bookmark_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_bookmark_white_24dp.png new file mode 100644 index 000000000..370cf8af5 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_bookmark_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_bug_report_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_bug_report_black_24dp.png new file mode 100644 index 000000000..6eb1474e3 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_bug_report_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_bug_report_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_bug_report_white_24dp.png new file mode 100644 index 000000000..e0b5b1964 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_bug_report_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_import_export_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_import_export_black_24dp.png new file mode 100644 index 000000000..36aa872e5 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_import_export_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_import_export_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_import_export_white_24dp.png new file mode 100644 index 000000000..a5e55a470 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_import_export_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_info_outline_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_info_outline_black_24dp.png new file mode 100644 index 000000000..c1e2a03a4 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_info_outline_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_info_outline_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_info_outline_white_24dp.png new file mode 100644 index 000000000..3a82cab3b Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_info_outline_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_black_24dp.png new file mode 100644 index 000000000..46020a7e0 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_check_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_check_black_24dp.png new file mode 100644 index 000000000..068c596a3 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_check_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_check_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_check_white_24dp.png new file mode 100644 index 000000000..767d066de Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_check_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_white_24dp.png new file mode 100644 index 000000000..70e74e4a2 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_save_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_save_black_24dp.png new file mode 100644 index 000000000..ba001835a Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_save_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_save_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_save_white_24dp.png new file mode 100644 index 000000000..bd80bf1f7 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_save_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_settings_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_settings_black_24dp.png new file mode 100644 index 000000000..476d5c978 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_settings_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_settings_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_settings_white_24dp.png new file mode 100644 index 000000000..507c5edd4 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_settings_white_24dp.png differ diff --git a/app/src/main/res/layout-land/activity_player_queue_control.xml b/app/src/main/res/layout-land/activity_player_queue_control.xml index a577b7fe0..11765f901 100644 --- a/app/src/main/res/layout-land/activity_player_queue_control.xml +++ b/app/src/main/res/layout-land/activity_player_queue_control.xml @@ -296,5 +296,19 @@ android:textColor="?attr/colorAccent" tools:ignore="HardcodedText" tools:text="1:23:49"/> + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 7eafc6c69..99c637389 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,12 +1,12 @@ + - + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main_player.xml b/app/src/main/res/layout/activity_main_player.xml index 36222f5bc..c581c3203 100644 --- a/app/src/main/res/layout/activity_main_player.xml +++ b/app/src/main/res/layout/activity_main_player.xml @@ -30,6 +30,12 @@ + @@ -128,6 +134,7 @@ tools:visibility="visible"> @@ -139,13 +146,14 @@ android:layout_alignParentTop="true" android:background="@drawable/player_top_controls_bg" android:gravity="top" - android:paddingBottom="70dp" - android:paddingLeft="2dp" - android:paddingRight="10dp" android:paddingTop="10dp" + android:paddingBottom="10dp" + android:paddingLeft="5dp" + android:paddingRight="5dp" tools:ignore="RtlHardcoded"> @@ -225,12 +235,12 @@ android:layout_marginLeft="2dp" android:layout_marginRight="2dp" android:layout_toLeftOf="@+id/moreOptionsButton" - android:background="#00ffffff" android:clickable="true" android:focusable="true" android:padding="5dp" android:scaleType="fitXY" android:src="@drawable/list" + android:background="?attr/selectableItemBackground" tools:ignore="ContentDescription,RtlHardcoded"/> + + + + + + + + + + + + - + + + tools:visibility="visible" /> + tools:visibility="visible" /> + tools:visibility="visible" /> diff --git a/app/src/main/res/layout/activity_player_queue_control.xml b/app/src/main/res/layout/activity_player_queue_control.xml index a59e5ba2e..7f649e382 100644 --- a/app/src/main/res/layout/activity_player_queue_control.xml +++ b/app/src/main/res/layout/activity_player_queue_control.xml @@ -146,6 +146,20 @@ android:textColor="?attr/colorAccent" tools:ignore="HardcodedText" tools:text="1:23:49"/> + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_playback_parameter.xml b/app/src/main/res/layout/dialog_playback_parameter.xml new file mode 100644 index 000000000..a8c6a5dcd --- /dev/null +++ b/app/src/main/res/layout/dialog_playback_parameter.xml @@ -0,0 +1,313 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_playlist_name.xml b/app/src/main/res/layout/dialog_playlist_name.xml new file mode 100644 index 000000000..2dfab228b --- /dev/null +++ b/app/src/main/res/layout/dialog_playlist_name.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_playlists.xml b/app/src/main/res/layout/dialog_playlists.xml new file mode 100644 index 000000000..c08aa315e --- /dev/null +++ b/app/src/main/res/layout/dialog_playlists.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/drawer_layout.xml b/app/src/main/res/layout/drawer_layout.xml new file mode 100644 index 000000000..aba90430c --- /dev/null +++ b/app/src/main/res/layout/drawer_layout.xml @@ -0,0 +1,117 @@ + + + + + +