diff --git a/app/build.gradle b/app/build.gradle
index 86d6542e0..4e8115af5 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -55,7 +55,7 @@ dependencies {
exclude module: 'support-annotations'
}
- implementation 'com.github.TeamNewPipe:NewPipeExtractor:7fd21ec08581d'
+ implementation 'com.github.TeamNewPipe:NewPipeExtractor:e51bc58a856dcf3'
testImplementation 'junit:junit:4.12'
testImplementation 'org.mockito:mockito-core:1.10.19'
@@ -89,4 +89,8 @@ dependencies {
implementation 'frankiesardo:icepick:3.2.0'
annotationProcessor 'frankiesardo:icepick-processor:3.2.0'
+
+ debugImplementation 'com.squareup.leakcanary:leakcanary-android:1.5.4'
+ betaImplementation 'com.squareup.leakcanary:leakcanary-android-no-op:1.5.4'
+ releaseImplementation 'com.squareup.leakcanary:leakcanary-android-no-op:1.5.4'
}
diff --git a/app/src/debug/java/org/schabi/newpipe/DebugApp.java b/app/src/debug/java/org/schabi/newpipe/DebugApp.java
index 1a507b4e5..ba1fd90cc 100644
--- a/app/src/debug/java/org/schabi/newpipe/DebugApp.java
+++ b/app/src/debug/java/org/schabi/newpipe/DebugApp.java
@@ -1,9 +1,21 @@
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.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 java.io.File;
+import java.util.concurrent.TimeUnit;
public class DebugApp extends App {
private static final String TAG = DebugApp.class.toString();
@@ -17,7 +29,6 @@ public class DebugApp extends App {
@Override
public void onCreate() {
super.onCreate();
-
initStetho();
}
@@ -42,4 +53,35 @@ public class DebugApp extends App {
// Initialize Stetho with the Initializer
Stetho.initialize(initializer);
}
+
+ @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 bc3dc62e6..e15d9abf8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -122,8 +122,12 @@
+
+
@@ -169,6 +173,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -176,17 +215,8 @@
-
-
-
-
-
-
-
-
-
-
+
@@ -195,68 +225,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..b15a38aae 100644
--- a/app/src/main/java/org/schabi/newpipe/App.java
+++ b/app/src/main/java/org/schabi/newpipe/App.java
@@ -1,17 +1,18 @@
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;
@@ -56,6 +57,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 extends ReportSenderFactory>[] reportSenderFactoryClasses = new Class[]{AcraReportSenderFactory.class};
@@ -71,6 +73,13 @@ 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);
@@ -80,8 +89,7 @@ public class App extends Application {
initNotificationChannel();
// Initialize image loader
- ImageLoaderConfiguration config = new ImageLoaderConfiguration.Builder(this).build();
- ImageLoader.getInstance().init(config);
+ ImageLoader.getInstance().init(getImageLoaderConfigurations(10, 50));
configureRxJavaErrorHandler();
}
@@ -119,6 +127,14 @@ public class App extends Application {
});
}
+ private ImageLoaderConfiguration getImageLoaderConfigurations(final int memoryCacheSizeMb,
+ final int diskCacheSizeMb) {
+ return new ImageLoaderConfiguration.Builder(this)
+ .memoryCache(new LRULimitedMemoryCache(memoryCacheSizeMb * 1024 * 1024))
+ .diskCacheSize(diskCacheSizeMb * 1024 * 1024)
+ .build();
+ }
+
private void initACRA() {
try {
final ACRAConfiguration acraConfig = new ConfigurationBuilder(this)
@@ -152,4 +168,13 @@ 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;
+ }
}
diff --git a/app/src/main/java/org/schabi/newpipe/BaseFragment.java b/app/src/main/java/org/schabi/newpipe/BaseFragment.java
index 6cd79e2c9..d3e4a4b28 100644
--- a/app/src/main/java/org/schabi/newpipe/BaseFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/BaseFragment.java
@@ -13,6 +13,7 @@ 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 +68,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
//////////////////////////////////////////////////////////////////////////*/
diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java
index 9e8f3fa76..ea6715f16 100644
--- a/app/src/main/java/org/schabi/newpipe/MainActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java
@@ -20,6 +20,7 @@
package org.schabi.newpipe;
+import android.annotation.SuppressLint;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
@@ -27,7 +28,6 @@ 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;
@@ -41,41 +41,23 @@ import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
+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.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.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;
/*//////////////////////////////////////////////////////////////////////////
@@ -86,7 +68,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,7 +79,6 @@ public class MainActivity extends AppCompatActivity implements HistoryListener {
setSupportActionBar(findViewById(R.id.toolbar));
setupDrawer();
- initHistory();
}
private void setupDrawer() {
@@ -149,8 +129,6 @@ public class MainActivity extends AppCompatActivity implements HistoryListener {
if (!isChangingConfigurations()) {
StateSaver.clearStateFiles();
}
-
- disposeHistory();
}
@Override
@@ -236,6 +214,22 @@ public class MainActivity extends AppCompatActivity implements HistoryListener {
}
}
+ @SuppressLint("ShowToast")
+ private void onHeapDumpToggled(@NonNull MenuItem item) {
+ final boolean isHeapDumpEnabled = !item.isChecked();
+
+ PreferenceManager.getDefaultSharedPreferences(this).edit()
+ .putBoolean(getString(R.string.allow_heap_dumping_key), isHeapDumpEnabled).apply();
+ item.setChecked(isHeapDumpEnabled);
+
+ final String heapDumpNotice;
+ if (isHeapDumpEnabled) {
+ heapDumpNotice = getString(R.string.enable_leak_canary_notice);
+ } else {
+ heapDumpNotice = getString(R.string.disable_leak_canary_notice);
+ }
+ Toast.makeText(getApplicationContext(), heapDumpNotice, Toast.LENGTH_SHORT).show();
+ }
/*//////////////////////////////////////////////////////////////////////////
// Menu
//////////////////////////////////////////////////////////////////////////*/
@@ -257,6 +251,10 @@ public class MainActivity extends AppCompatActivity implements HistoryListener {
inflater.inflate(R.menu.main_menu, menu);
}
+ if (DEBUG) {
+ getMenuInflater().inflate(R.menu.debug_menu, menu);
+ }
+
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.setDisplayHomeAsUpEnabled(false);
@@ -267,6 +265,17 @@ public class MainActivity extends AppCompatActivity implements HistoryListener {
return true;
}
+ @Override
+ public boolean onPrepareOptionsMenu(Menu menu) {
+ MenuItem heapDumpToggle = menu.findItem(R.id.action_toggle_heap_dump);
+ if (heapDumpToggle != null) {
+ final boolean isToggled = PreferenceManager.getDefaultSharedPreferences(this)
+ .getBoolean(getString(R.string.allow_heap_dumping_key), false);
+ heapDumpToggle.setChecked(isToggled);
+ }
+ return super.onPrepareOptionsMenu(menu);
+ }
+
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (DEBUG) Log.d(TAG, "onOptionsItemSelected() called with: item = [" + item + "]");
@@ -287,6 +296,9 @@ public class MainActivity extends AppCompatActivity implements HistoryListener {
case R.id.action_history:
NavigationHelper.openHistory(this);
return true;
+ case R.id.action_toggle_heap_dump:
+ onHeapDumpToggled(item);
+ return true;
default:
return super.onOptionsItemSelected(item);
}
@@ -357,75 +369,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..7b33d0c10 100644
--- a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java
+++ b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java
@@ -7,6 +7,7 @@ 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 {
@@ -17,15 +18,24 @@ public final class NewPipeDatabase {
}
public static void init(Context context) {
- databaseInstance = Room.databaseBuilder(context.getApplicationContext(),
- AppDatabase.class, DATABASE_NAME
- ).build();
+ databaseInstance = Room
+ .databaseBuilder(context, AppDatabase.class, DATABASE_NAME)
+ .addMigrations(MIGRATION_11_12)
+ .fallbackToDestructiveMigration()
+ .build();
}
@NonNull
+ @Deprecated
public static AppDatabase getInstance() {
if (databaseInstance == null) throw new RuntimeException("Database not initialized");
return databaseInstance;
}
+
+ @NonNull
+ public static AppDatabase getInstance(Context context) {
+ if (databaseInstance == null) init(context);
+ return databaseInstance;
+ }
}
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 extends Info> 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 extends Info> 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..2fddaa1bb
--- /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.service_id, item.name, item.url, item.stream_type, item.thumbnail_url,
+ item.uploader_name, item.duration);
+ }
+
+ @Ignore
+ public StreamEntity(final StreamInfo info) {
+ this(info.service_id, info.name, info.url, info.stream_type, info.thumbnail_url,
+ info.uploader_name, info.duration);
+ }
+
+ @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/SubscriptionEntity.java b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java
index e71088ac9..60eb0c3d3 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
@@ -50,8 +50,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;
}
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..abc150e7d 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);
}
}
@@ -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
index 27bffca2d..d928166ab 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/ActionBarHandler.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/ActionBarHandler.java
@@ -53,7 +53,6 @@ class ActionBarHandler {
// 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.
@@ -117,11 +116,6 @@ class ActionBarHandler {
}
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);
@@ -145,19 +139,12 @@ class ActionBarHandler {
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..206f6edd8 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
@@ -58,7 +58,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem;
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;
@@ -75,6 +75,7 @@ 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;
@@ -145,6 +146,8 @@ public class VideoDetailFragment extends BaseStateFragment implement
private TextView detailControlsBackground;
private TextView detailControlsPopup;
+ private TextView detailControlsAddToPlaylist;
+ private TextView detailControlsDownload;
private TextView appendControlsDetail;
private LinearLayout videoDescriptionRootLayout;
@@ -327,6 +330,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,
+ actionBarHandler.getSelectedVideoStream());
+ 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");
@@ -429,6 +456,8 @@ 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);
videoDescriptionRootLayout = rootView.findViewById(R.id.detail_description_root_layout);
@@ -462,7 +491,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 +508,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 +529,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,21 +546,14 @@ 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;
+ 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, new Runnable() {
- @Override
- public void run() {
- animateView(appendControlsDetail, false, 1500, 1000);
- }
- });
- }
- return false;
+ if (motionEvent.getAction() == MotionEvent.ACTION_DOWN) {
+ animateView(appendControlsDetail, true, 250, 0, () ->
+ animateView(appendControlsDetail, false, 1500, 1000));
}
+ return false;
};
}
@@ -605,18 +626,9 @@ public class VideoDetailFragment extends BaseStateFragment implement
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();
}
@@ -626,44 +638,19 @@ public class VideoDetailFragment extends BaseStateFragment implement
actionBarHandler.setupStreamList(sortedStreamVideosList, spinnerToolbar);
actionBarHandler.setOnShareListener(selectedStreamId -> shareUrl(info.name, info.url));
- actionBarHandler.setOnOpenInBrowserListener(new ActionBarHandler.OnActionListener() {
- @Override
- public void onActionSelected(int selectedStreamId) {
- openUrlInBrowser(info.getUrl());
+ actionBarHandler.setOnOpenInBrowserListener((int selectedStreamId)->
+ openUrlInBrowser(info.getUrl()));
+
+ actionBarHandler.setOnPlayWithKodiListener((int selectedStreamId) -> {
+ try {
+ NavigationHelper.playWithKore(activity, Uri.parse(
+ info.getUrl().replace("https", "http")));
+ } catch (Exception e) {
+ if(DEBUG) Log.i(TAG, "Failed to start kore", e);
+ showInstallKoreDialog(activity);
}
});
- 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();
- }
- }
- });
}
/*//////////////////////////////////////////////////////////////////////////
@@ -770,20 +757,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);
});
}
@@ -794,10 +775,6 @@ 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);
- }
-
boolean useExternalAudioPlayer = PreferenceManager.getDefaultSharedPreferences(activity)
.getBoolean(activity.getString(R.string.use_external_audio_player_key), false);
@@ -814,10 +791,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,10 +806,6 @@ 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);
} else {
@@ -889,9 +858,7 @@ 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 {
+ .map((@io.reactivex.annotations.NonNull String description) -> {
Spanned parsedDescription;
if (Build.VERSION.SDK_INT >= 24) {
parsedDescription = Html.fromHtml(description, 0);
@@ -900,16 +867,12 @@ public class VideoDetailFragment extends BaseStateFragment implement
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 {
+ .subscribe((@io.reactivex.annotations.NonNull Spanned spanned) -> {
videoDescriptionView.setText(spanned);
videoDescriptionView.setVisibility(View.VISIBLE);
- }
}));
}
@@ -1033,6 +996,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);
}
@@ -1135,14 +1099,11 @@ public class VideoDetailFragment extends BaseStateFragment implement
}
public void onBlockedByGemaError() {
- thumbnailBackgroundButton.setOnClickListener(new View.OnClickListener() {
- @Override
- public void onClick(View v) {
+ 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..8c9945149 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,7 +137,7 @@ 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);
@@ -155,7 +152,7 @@ public abstract class BaseListFragment extends BaseStateFragment implem
}
});
- infoListAdapter.setOnChannelSelectedListener(new InfoItemBuilder.OnInfoItemSelectedListener() {
+ infoListAdapter.setOnChannelSelectedListener(new OnClickGesture() {
@Override
public void selected(ChannelInfoItem selectedItem) {
onItemSelected(selectedItem);
@@ -163,12 +160,9 @@ public abstract class BaseListFragment extends BaseStateFragment implem
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);
@@ -176,9 +170,6 @@ public abstract class BaseListFragment extends BaseStateFragment implem
useAsFrontPage?getParentFragment().getFragmentManager():getFragmentManager(),
selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName());
}
-
- @Override
- public void held(PlaylistInfoItem selectedItem) {}
});
itemsList.clearOnScrollListeners();
@@ -203,22 +194,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;
}
};
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..a7f513de9 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
@@ -194,17 +194,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 +222,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);
}
@@ -428,6 +424,7 @@ 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()) {
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..db382ef5d 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,13 +17,18 @@ 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.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.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;
@@ -31,13 +36,27 @@ 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.ThemeHelper;
+
+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 +73,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 +86,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 +116,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 +141,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 +169,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;
}
/*//////////////////////////////////////////////////////////////////////////
@@ -166,10 +221,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,12 +258,11 @@ 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())
+ );
}
}
@@ -219,24 +275,21 @@ public class PlaylistFragment extends BaseListInfoFragment {
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() {
@@ -280,9 +333,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..0638c06e7 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,16 +61,11 @@ 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;
@@ -121,7 +113,7 @@ public class SearchFragment extends BaseListFragment suggestionPublisher
+ .onNext(searchEditText.getText().toString()),
+
+ throwable -> showSnackBarError(throwable,
+ UserAction.SOMETHING_ELSE, "none",
+ "Deleting item failed", R.string.general_error)
+ );
+
new AlertDialog.Builder(activity)
.setTitle(item.query)
.setMessage(R.string.delete_item_search_history)
.setCancelable(true)
.setNegativeButton(R.string.cancel, null)
- .setPositiveButton(R.string.delete, new DialogInterface.OnClickListener() {
- @Override
- public void onClick(DialogInterface dialog, int which) {
- disposables.add(Observable
- .fromCallable(new Callable() {
- @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) -> disposables.add(onDelete))
+ .show();
}
@Override
@@ -589,83 +569,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 +682,14 @@ public class SearchFragment extends BaseListFragment {},
+ error -> showSnackBarError(error, UserAction.SEARCHED,
+ NewPipe.getNameOfService(serviceId), query, 0)
+ );
+ suggestionPublisher.onNext(query);
startLoading(false);
}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/HeaderFooterHolder.java b/app/src/main/java/org/schabi/newpipe/fragments/local/HeaderFooterHolder.java
new file mode 100644
index 000000000..3c0830751
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/local/HeaderFooterHolder.java
@@ -0,0 +1,13 @@
+package org.schabi.newpipe.fragments.local;
+
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+
+public class HeaderFooterHolder extends RecyclerView.ViewHolder {
+ public View view;
+
+ public HeaderFooterHolder(View v) {
+ super(v);
+ view = v;
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/LocalItemBuilder.java b/app/src/main/java/org/schabi/newpipe/fragments/local/LocalItemBuilder.java
new file mode 100644
index 000000000..4794def97
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/local/LocalItemBuilder.java
@@ -0,0 +1,62 @@
+package org.schabi.newpipe.fragments.local;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.widget.ImageView;
+
+import com.nostra13.universalimageloader.core.DisplayImageOptions;
+import com.nostra13.universalimageloader.core.ImageLoader;
+import com.nostra13.universalimageloader.core.process.BitmapProcessor;
+
+import org.schabi.newpipe.database.LocalItem;
+import org.schabi.newpipe.util.OnClickGesture;
+
+/*
+ * Created by Christian Schabesberger on 26.09.16.
+ *
+ * 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 extends LocalItem> 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..d2c4e1b14
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/local/bookmark/BaseLocalListFragment.java
@@ -0,0 +1,175 @@
+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) {
+ itemsList.post(() -> 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..40637e149
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/local/dialog/PlaylistAppendDialog.java
@@ -0,0 +1,163 @@
+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 javax.annotation.Nonnull;
+
+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