diff --git a/app/build.gradle b/app/build.gradle index 86d6542e0..273616f91 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:4fb49d54b5' testImplementation 'junit:junit:4.12' testImplementation 'org.mockito:mockito-core:1.10.19' diff --git a/app/src/debug/java/org/schabi/newpipe/DebugApp.java b/app/src/debug/java/org/schabi/newpipe/DebugApp.java index 1a507b4e5..4d37094ba 100644 --- a/app/src/debug/java/org/schabi/newpipe/DebugApp.java +++ b/app/src/debug/java/org/schabi/newpipe/DebugApp.java @@ -17,7 +17,6 @@ public class DebugApp extends App { @Override public void onCreate() { super.onCreate(); - initStetho(); } diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java index 49f73853b..79221db7f 100644 --- a/app/src/main/java/org/schabi/newpipe/App.java +++ b/app/src/main/java/org/schabi/newpipe/App.java @@ -1,15 +1,13 @@ 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.util.Log; +import com.nostra13.universalimageloader.cache.memory.impl.LRULimitedMemoryCache; import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.ImageLoaderConfiguration; @@ -80,8 +78,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 +116,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) diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index 9e8f3fa76..9a1ecd07a 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -26,8 +26,6 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.preference.PreferenceManager; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; import android.support.design.widget.NavigationView; import android.support.v4.app.Fragment; import android.support.v4.view.GravityCompat; @@ -42,40 +40,21 @@ import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; -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 +65,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 +76,6 @@ public class MainActivity extends AppCompatActivity implements HistoryListener { setSupportActionBar(findViewById(R.id.toolbar)); setupDrawer(); - initHistory(); } private void setupDrawer() { @@ -149,8 +126,6 @@ public class MainActivity extends AppCompatActivity implements HistoryListener { if (!isChangingConfigurations()) { StateSaver.clearStateFiles(); } - - disposeHistory(); } @Override @@ -357,75 +332,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/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..4512e316f 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; @@ -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/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index c7b61eceb..89f35c306 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,7 @@ public class VideoDetailFragment extends BaseStateFragment implement private TextView detailControlsBackground; private TextView detailControlsPopup; + private TextView detailControlsAddToPlaylist; private TextView appendControlsDetail; private LinearLayout videoDescriptionRootLayout; @@ -327,6 +329,12 @@ 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_uploader_root_layout: if (TextUtils.isEmpty(currentInfo.getUploaderUrl())) { Log.w(TAG, "Can't open channel because we got no channel URL"); @@ -429,6 +437,7 @@ 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); appendControlsDetail = rootView.findViewById(R.id.touch_append_detail); videoDescriptionRootLayout = rootView.findViewById(R.id.detail_description_root_layout); @@ -462,7 +471,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 +488,7 @@ public class VideoDetailFragment extends BaseStateFragment implement thumbnailBackgroundButton.setOnClickListener(this); detailControlsBackground.setOnClickListener(this); detailControlsPopup.setOnClickListener(this); + detailControlsAddToPlaylist.setOnClickListener(this); relatedStreamExpandButton.setOnClickListener(this); detailControlsBackground.setLongClickable(true); @@ -638,9 +648,6 @@ public class VideoDetailFragment extends BaseStateFragment implement 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); @@ -794,10 +801,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 +817,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 +832,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 { @@ -1033,6 +1028,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); } 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 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 objectsToSave) { + objectsToSave.add(streamEntities); + } + + @Override + @SuppressWarnings("unchecked") + public void readFrom(@NonNull Queue savedObjects) throws Exception { + streamEntities = (List) savedObjects.poll(); + } + + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + if (getActivity() != null) { + savedState = StateSaver.tryToSave(getActivity().isChangingConfigurations(), + savedState, outState, this); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalItemHolder.java b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalItemHolder.java new file mode 100644 index 000000000..e4087d8a8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalItemHolder.java @@ -0,0 +1,63 @@ +package org.schabi.newpipe.fragments.local.holder; + +import android.graphics.Bitmap; +import android.support.annotation.DimenRes; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.ViewGroup; +import android.widget.ImageView; + +import com.nostra13.universalimageloader.core.DisplayImageOptions; +import com.nostra13.universalimageloader.core.process.BitmapProcessor; + +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.fragments.local.LocalItemBuilder; + +import java.text.DateFormat; + +/* + * Created by Christian Schabesberger on 12.02.17. + * + * Copyright (C) Christian Schabesberger 2016 + * InfoItemHolder.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ + +public abstract class LocalItemHolder extends RecyclerView.ViewHolder { + protected final LocalItemBuilder itemBuilder; + + public LocalItemHolder(LocalItemBuilder itemBuilder, int layoutId, ViewGroup parent) { + super(LayoutInflater.from(itemBuilder.getContext()) + .inflate(layoutId, parent, false)); + this.itemBuilder = itemBuilder; + } + + public abstract void updateFromItem(final LocalItem item, final DateFormat dateFormat); + + /*////////////////////////////////////////////////////////////////////////// + // ImageLoaderOptions + //////////////////////////////////////////////////////////////////////////*/ + + /** + * Base display options + */ + public static final DisplayImageOptions BASE_DISPLAY_IMAGE_OPTIONS = + new DisplayImageOptions.Builder() + .cacheInMemory(true) + .cacheOnDisk(true) + .bitmapConfig(Bitmap.Config.RGB_565) + .resetViewBeforeLoading(false) + .build(); +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalPlaylistItemHolder.java new file mode 100644 index 000000000..1fbea6cc4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalPlaylistItemHolder.java @@ -0,0 +1,36 @@ +package org.schabi.newpipe.fragments.local.holder; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import com.nostra13.universalimageloader.core.DisplayImageOptions; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; +import org.schabi.newpipe.fragments.local.LocalItemBuilder; + +import java.text.DateFormat; + +public class LocalPlaylistItemHolder extends PlaylistItemHolder { + + public LocalPlaylistItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) { + super(infoItemBuilder, parent); + } + + @Override + public void updateFromItem(final LocalItem localItem, final DateFormat dateFormat) { + if (!(localItem instanceof PlaylistMetadataEntry)) return; + final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem; + + itemTitleView.setText(item.name); + itemStreamCountView.setText(String.valueOf(item.streamCount)); + itemUploaderView.setVisibility(View.INVISIBLE); + + itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView, DISPLAY_THUMBNAIL_OPTIONS); + + super.updateFromItem(localItem, dateFormat); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalPlaylistStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalPlaylistStreamItemHolder.java new file mode 100644 index 000000000..0696f5f61 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalPlaylistStreamItemHolder.java @@ -0,0 +1,106 @@ +package org.schabi.newpipe.fragments.local.holder; + +import android.graphics.Bitmap; +import android.support.v4.content.ContextCompat; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import com.nostra13.universalimageloader.core.DisplayImageOptions; +import com.nostra13.universalimageloader.core.assist.ImageScaleType; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.fragments.local.LocalItemBuilder; +import org.schabi.newpipe.util.Localization; + +import java.text.DateFormat; + +public class LocalPlaylistStreamItemHolder extends LocalItemHolder { + + public final ImageView itemThumbnailView; + public final TextView itemVideoTitleView; + public final TextView itemAdditionalDetailsView; + public final TextView itemDurationView; + public final View itemHandleView; + + LocalPlaylistStreamItemHolder(LocalItemBuilder infoItemBuilder, int layoutId, ViewGroup parent) { + super(infoItemBuilder, layoutId, parent); + + itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); + itemVideoTitleView = itemView.findViewById(R.id.itemVideoTitleView); + itemAdditionalDetailsView = itemView.findViewById(R.id.itemAdditionalDetails); + itemDurationView = itemView.findViewById(R.id.itemDurationView); + itemHandleView = itemView.findViewById(R.id.itemHandle); + } + + public LocalPlaylistStreamItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) { + this(infoItemBuilder, R.layout.list_stream_playlist_item, parent); + } + + @Override + public void updateFromItem(final LocalItem localItem, final DateFormat dateFormat) { + if (!(localItem instanceof PlaylistStreamEntry)) return; + final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem; + + itemVideoTitleView.setText(item.title); + itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.uploader, + NewPipe.getNameOfService(item.serviceId))); + + if (item.duration > 0) { + itemDurationView.setText(Localization.getDurationString(item.duration)); + itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), + R.color.duration_background_color)); + itemDurationView.setVisibility(View.VISIBLE); + } else { + itemDurationView.setVisibility(View.GONE); + } + + // Default thumbnail is shown on error, while loading and if the url is empty + itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView, DISPLAY_THUMBNAIL_OPTIONS); + + itemView.setOnClickListener(view -> { + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().selected(item); + } + }); + + itemView.setLongClickable(true); + itemView.setOnLongClickListener(view -> { + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().held(item); + } + return true; + }); + + itemThumbnailView.setOnTouchListener(getOnTouchListener(item)); + itemHandleView.setOnTouchListener(getOnTouchListener(item)); + } + + private View.OnTouchListener getOnTouchListener(final PlaylistStreamEntry item) { + return (view, motionEvent) -> { + view.performClick(); + if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null && + motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { + itemBuilder.getOnItemSelectedListener().drag(item, + LocalPlaylistStreamItemHolder.this); + } + return false; + }; + } + + /** + * Display options for stream thumbnails + */ + private static final DisplayImageOptions DISPLAY_THUMBNAIL_OPTIONS = + new DisplayImageOptions.Builder() + .cloneFrom(BASE_DISPLAY_IMAGE_OPTIONS) + .showImageOnFail(R.drawable.dummy_thumbnail) + .showImageForEmptyUri(R.drawable.dummy_thumbnail) + .showImageOnLoading(R.drawable.dummy_thumbnail) + .build(); +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalStatisticStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalStatisticStreamItemHolder.java new file mode 100644 index 000000000..cd0630b37 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/LocalStatisticStreamItemHolder.java @@ -0,0 +1,114 @@ +package org.schabi.newpipe.fragments.local.holder; + +import android.support.v4.content.ContextCompat; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import com.nostra13.universalimageloader.core.DisplayImageOptions; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.stream.StreamStatisticsEntry; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.fragments.local.LocalItemBuilder; +import org.schabi.newpipe.util.Localization; + +import java.text.DateFormat; + +/* + * Created by Christian Schabesberger on 01.08.16. + *

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

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

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

+ * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ + +public class LocalStatisticStreamItemHolder extends LocalItemHolder { + + public final ImageView itemThumbnailView; + public final TextView itemVideoTitleView; + public final TextView itemUploaderView; + public final TextView itemDurationView; + public final TextView itemAdditionalDetails; + + public LocalStatisticStreamItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) { + super(infoItemBuilder, R.layout.list_stream_item, parent); + + itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); + itemVideoTitleView = itemView.findViewById(R.id.itemVideoTitleView); + itemUploaderView = itemView.findViewById(R.id.itemUploaderView); + itemDurationView = itemView.findViewById(R.id.itemDurationView); + itemAdditionalDetails = itemView.findViewById(R.id.itemAdditionalDetails); + } + + private String getStreamInfoDetailLine(final StreamStatisticsEntry entry, + final DateFormat dateFormat) { + final String watchCount = Localization.shortViewCount(itemBuilder.getContext(), + entry.watchCount); + final String uploadDate = dateFormat.format(entry.latestAccessDate); + final String serviceName = NewPipe.getNameOfService(entry.serviceId); + return Localization.concatenateStrings(watchCount, uploadDate, serviceName); + } + + @Override + public void updateFromItem(final LocalItem localItem, final DateFormat dateFormat) { + if (!(localItem instanceof StreamStatisticsEntry)) return; + final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem; + + itemVideoTitleView.setText(item.title); + itemUploaderView.setText(item.uploader); + + if (item.duration > 0) { + itemDurationView.setText(Localization.getDurationString(item.duration)); + itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), + R.color.duration_background_color)); + itemDurationView.setVisibility(View.VISIBLE); + } else { + itemDurationView.setVisibility(View.GONE); + } + + itemAdditionalDetails.setText(getStreamInfoDetailLine(item, dateFormat)); + + // Default thumbnail is shown on error, while loading and if the url is empty + itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView, DISPLAY_THUMBNAIL_OPTIONS); + + itemView.setOnClickListener(view -> { + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().selected(item); + } + }); + + itemView.setLongClickable(true); + itemView.setOnLongClickListener(view -> { + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().held(item); + } + return true; + }); + } + + /** + * Display options for stream thumbnails + */ + public static final DisplayImageOptions DISPLAY_THUMBNAIL_OPTIONS = + new DisplayImageOptions.Builder() + .cloneFrom(BASE_DISPLAY_IMAGE_OPTIONS) + .showImageOnFail(R.drawable.dummy_thumbnail) + .showImageForEmptyUri(R.drawable.dummy_thumbnail) + .showImageOnLoading(R.drawable.dummy_thumbnail) + .build(); +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/holder/PlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/PlaylistItemHolder.java new file mode 100644 index 000000000..bab76ddcb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/PlaylistItemHolder.java @@ -0,0 +1,62 @@ +package org.schabi.newpipe.fragments.local.holder; + +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import com.nostra13.universalimageloader.core.DisplayImageOptions; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.fragments.local.LocalItemBuilder; + +import java.text.DateFormat; + +public abstract class PlaylistItemHolder extends LocalItemHolder { + public final ImageView itemThumbnailView; + public final TextView itemStreamCountView; + public final TextView itemTitleView; + public final TextView itemUploaderView; + + public PlaylistItemHolder(LocalItemBuilder infoItemBuilder, + int layoutId, ViewGroup parent) { + super(infoItemBuilder, layoutId, parent); + + itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); + itemTitleView = itemView.findViewById(R.id.itemTitleView); + itemStreamCountView = itemView.findViewById(R.id.itemStreamCountView); + itemUploaderView = itemView.findViewById(R.id.itemUploaderView); + } + + public PlaylistItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) { + this(infoItemBuilder, R.layout.list_playlist_mini_item, parent); + } + + @Override + public void updateFromItem(final LocalItem localItem, final DateFormat dateFormat) { + itemView.setOnClickListener(view -> { + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().selected(localItem); + } + }); + + itemView.setLongClickable(true); + itemView.setOnLongClickListener(view -> { + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().held(localItem); + } + return true; + }); + } + + /** + * Display options for playlist thumbnails + */ + public static final DisplayImageOptions DISPLAY_THUMBNAIL_OPTIONS = + new DisplayImageOptions.Builder() + .cloneFrom(BASE_DISPLAY_IMAGE_OPTIONS) + .showImageOnLoading(R.drawable.dummy_thumbnail_playlist) + .showImageForEmptyUri(R.drawable.dummy_thumbnail_playlist) + .showImageOnFail(R.drawable.dummy_thumbnail_playlist) + .build(); +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/local/holder/RemotePlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/RemotePlaylistItemHolder.java new file mode 100644 index 000000000..0f7b00e6d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/local/holder/RemotePlaylistItemHolder.java @@ -0,0 +1,33 @@ +package org.schabi.newpipe.fragments.local.holder; + +import android.view.ViewGroup; + +import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.fragments.local.LocalItemBuilder; +import org.schabi.newpipe.util.Localization; + +import java.text.DateFormat; + +public class RemotePlaylistItemHolder extends PlaylistItemHolder { + public RemotePlaylistItemHolder(LocalItemBuilder infoItemBuilder, ViewGroup parent) { + super(infoItemBuilder, parent); + } + + @Override + public void updateFromItem(final LocalItem localItem, final DateFormat dateFormat) { + if (!(localItem instanceof PlaylistRemoteEntity)) return; + final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem; + + itemTitleView.setText(item.getName()); + itemStreamCountView.setText(String.valueOf(item.getStreamCount())); + itemUploaderView.setText(Localization.concatenateStrings(item.getUploader(), + NewPipe.getNameOfService(item.getServiceId()))); + + itemBuilder.displayImage(item.getThumbnailUrl(), itemThumbnailView, + DISPLAY_THUMBNAIL_OPTIONS); + + super.updateFromItem(localItem, dateFormat); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionFragment.java index 662f617bb..a91cca908 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/subscription/SubscriptionFragment.java @@ -16,10 +16,10 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.fragments.BaseStateFragment; -import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.info_list.InfoListAdapter; import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.OnClickGesture; import java.util.ArrayList; import java.util.Collections; @@ -125,24 +125,17 @@ public class SubscriptionFragment extends BaseStateFragment() { + infoListAdapter.setOnChannelSelectedListener(new OnClickGesture() { @Override public void selected(ChannelInfoItem selectedItem) { // Requires the parent fragment to find holder for fragment replacement - NavigationHelper.openChannelFragment(getParentFragment().getFragmentManager(), selectedItem.getServiceId(), selectedItem.url, selectedItem.getName()); - - } - - @Override - public void held(ChannelInfoItem selectedItem) {} - }); - - headerRootLayout.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View view) { - NavigationHelper.openWhatsNewFragment(getParentFragment().getFragmentManager()); + NavigationHelper.openChannelFragment(getParentFragment().getFragmentManager(), + selectedItem.getServiceId(), selectedItem.url, selectedItem.getName()); } }); + + headerRootLayout.setOnClickListener(view -> + NavigationHelper.openWhatsNewFragment(getParentFragment().getFragmentManager())); } private void resetFragment() { diff --git a/app/src/main/java/org/schabi/newpipe/history/HistoryActivity.java b/app/src/main/java/org/schabi/newpipe/history/HistoryActivity.java index 8d8e4ef16..267d07065 100644 --- a/app/src/main/java/org/schabi/newpipe/history/HistoryActivity.java +++ b/app/src/main/java/org/schabi/newpipe/history/HistoryActivity.java @@ -11,7 +11,6 @@ import android.support.v4.app.FragmentStatePagerAdapter; import android.support.v4.view.ViewPager; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.Toolbar; -import android.util.Log; import android.view.Menu; import android.view.MenuItem; @@ -22,7 +21,6 @@ import org.schabi.newpipe.settings.SettingsActivity; import org.schabi.newpipe.util.ThemeHelper; import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.functions.Consumer; public class HistoryActivity extends AppCompatActivity { @@ -50,8 +48,10 @@ public class HistoryActivity extends AppCompatActivity { Toolbar toolbar = findViewById(R.id.toolbar); setSupportActionBar(toolbar); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - getSupportActionBar().setTitle(R.string.title_activity_history); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setTitle(R.string.title_activity_history); + } // Create the adapter that will return a fragment for each of the three // primary sections of the activity. mSectionsPagerAdapter = new SectionsPagerAdapter(getSupportFragmentManager()); @@ -66,17 +66,11 @@ public class HistoryActivity extends AppCompatActivity { final FloatingActionButton fab = findViewById(R.id.fab); RxView.clicks(fab) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Consumer() { - @Override - public void accept(Object o) { - int currentItem = mViewPager.getCurrentItem(); - HistoryFragment fragment = (HistoryFragment) mSectionsPagerAdapter.instantiateItem(mViewPager, currentItem); - if(fragment != null) { - fragment.onHistoryCleared(); - } else { - Log.w(TAG, "Couldn't find current fragment"); - } - } + .subscribe(ignored -> { + int currentItem = mViewPager.getCurrentItem(); + HistoryFragment fragment = (HistoryFragment) mSectionsPagerAdapter + .instantiateItem(mViewPager, currentItem); + fragment.onHistoryCleared(); }); } @@ -119,7 +113,7 @@ public class HistoryActivity extends AppCompatActivity { fragment = SearchHistoryFragment.newInstance(); break; case 1: - fragment = WatchedHistoryFragment.newInstance(); + fragment = WatchHistoryFragment.newInstance(); break; default: throw new IllegalArgumentException("position: " + position); diff --git a/app/src/main/java/org/schabi/newpipe/history/HistoryEntryAdapter.java b/app/src/main/java/org/schabi/newpipe/history/HistoryEntryAdapter.java index d56469a7e..4170a1139 100644 --- a/app/src/main/java/org/schabi/newpipe/history/HistoryEntryAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/history/HistoryEntryAdapter.java @@ -1,12 +1,12 @@ package org.schabi.newpipe.history; import android.content.Context; +import android.content.res.Resources; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v7.widget.RecyclerView; -import android.view.View; -import org.schabi.newpipe.database.history.model.HistoryEntry; +import org.schabi.newpipe.util.Localization; import java.text.DateFormat; import java.util.ArrayList; @@ -19,19 +19,20 @@ import java.util.Date; * @param the type of the entries * @param the type of the view holder */ -public abstract class HistoryEntryAdapter extends RecyclerView.Adapter { +public abstract class HistoryEntryAdapter extends RecyclerView.Adapter { private final ArrayList mEntries; private final DateFormat mDateFormat; + private final Context mContext; private OnHistoryItemClickListener onHistoryItemClickListener = null; public HistoryEntryAdapter(Context context) { super(); + mContext = context; mEntries = new ArrayList<>(); - mDateFormat = android.text.format.DateFormat.getDateFormat(context.getApplicationContext()); - - setHasStableIds(true); + mDateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM, + Localization.getPreferredLocale(context)); } public void setEntries(@NonNull Collection historyEntries) { @@ -53,9 +54,8 @@ public abstract class HistoryEntryAdapter historyItemClickListener = onHistoryItemClickListener; - if(historyItemClickListener != null) { - historyItemClickListener.onHistoryItemClick(entry); - } + holder.itemView.setOnClickListener(v -> { + if(onHistoryItemClickListener != null) { + onHistoryItemClickListener.onHistoryItemClick(entry); } }); + + holder.itemView.setOnLongClickListener(view -> { + if (onHistoryItemClickListener != null) { + onHistoryItemClickListener.onHistoryItemLongClick(entry); + return true; + } + return false; + }); + onBindViewHolder(holder, entry, position); } @@ -94,13 +99,8 @@ public abstract class HistoryEntryAdapter { - void onHistoryItemClick(E historyItem); + public interface OnHistoryItemClickListener { + void onHistoryItemClick(E item); + void onHistoryItemLongClick(E item); } } diff --git a/app/src/main/java/org/schabi/newpipe/history/HistoryFragment.java b/app/src/main/java/org/schabi/newpipe/history/HistoryFragment.java index c64689775..14bd93c57 100644 --- a/app/src/main/java/org/schabi/newpipe/history/HistoryFragment.java +++ b/app/src/main/java/org/schabi/newpipe/history/HistoryFragment.java @@ -2,7 +2,6 @@ package org.schabi.newpipe.history; import android.content.SharedPreferences; -import android.graphics.Color; import android.os.Bundle; import android.os.Parcelable; import android.preference.PreferenceManager; @@ -12,34 +11,33 @@ import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.StringRes; import android.support.design.widget.Snackbar; +import android.support.v7.app.AlertDialog; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; -import android.support.v7.widget.helper.ItemTouchHelper; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; import org.schabi.newpipe.BaseFragment; import org.schabi.newpipe.R; -import org.schabi.newpipe.database.history.dao.HistoryDAO; -import org.schabi.newpipe.database.history.model.HistoryEntry; import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; import java.util.List; import icepick.State; -import io.reactivex.Observer; +import io.reactivex.Flowable; +import io.reactivex.Single; import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.Disposable; -import io.reactivex.functions.Consumer; -import io.reactivex.schedulers.Schedulers; -import io.reactivex.subjects.PublishSubject; import static org.schabi.newpipe.util.AnimationUtils.animateView; -public abstract class HistoryFragment extends BaseFragment +public abstract class HistoryFragment extends BaseFragment implements HistoryEntryAdapter.OnHistoryItemClickListener { private SharedPreferences mSharedPreferences; @@ -54,12 +52,11 @@ public abstract class HistoryFragment extends BaseFragme Parcelable mRecyclerViewState; private RecyclerView mRecyclerView; private HistoryEntryAdapter mHistoryAdapter; - private ItemTouchHelper.SimpleCallback mHistoryItemSwipeCallback; - // private int allowedSwipeToDeleteDirections = ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; - private HistoryDAO mHistoryDataSource; - private PublishSubject> mHistoryEntryDeleteSubject; - private PublishSubject> mHistoryEntryInsertSubject; + private Subscription historySubscription; + + protected HistoryRecordManager historyRecordManager; + protected CompositeDisposable disposables; @StringRes abstract int getEnabledConfigKey(); @@ -77,88 +74,47 @@ public abstract class HistoryFragment extends BaseFragme // Register history enabled listener mSharedPreferences.registerOnSharedPreferenceChangeListener(mHistoryIsEnabledChangeListener); - mHistoryDataSource = createHistoryDAO(); - - mHistoryEntryDeleteSubject = PublishSubject.create(); - mHistoryEntryDeleteSubject - .observeOn(Schedulers.io()) - .subscribe(new Consumer>() { - @Override - public void accept(Collection historyEntries) throws Exception { - mHistoryDataSource.delete(historyEntries); - } - }); - - mHistoryEntryInsertSubject = PublishSubject.create(); - mHistoryEntryInsertSubject - .observeOn(Schedulers.io()) - .subscribe(new Consumer>() { - @Override - public void accept(Collection historyEntries) throws Exception { - mHistoryDataSource.insertAll(historyEntries); - } - }); - - - } - - protected void historyItemSwipeCallback(int swipeDirection) { - mHistoryItemSwipeCallback = new ItemTouchHelper.SimpleCallback(0, swipeDirection) { - @Override - public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) { - return false; - } - - @Override - public void onSwiped(RecyclerView.ViewHolder viewHolder, int swipeDir) { - if (mHistoryAdapter != null) { - final E historyEntry = mHistoryAdapter.removeItemAt(viewHolder.getAdapterPosition()); - mHistoryEntryDeleteSubject.onNext(Collections.singletonList(historyEntry)); - - View view = getActivity().findViewById(R.id.main_content); - if (view == null) view = mRecyclerView.getRootView(); - - Snackbar.make(view, R.string.item_deleted, 5 * 1000) - .setActionTextColor(Color.WHITE) - .setAction(R.string.undo, new View.OnClickListener() { - @Override - public void onClick(View v) { - mHistoryEntryInsertSubject.onNext(Collections.singletonList(historyEntry)); - } - }).show(); - } - } - }; + historyRecordManager = new HistoryRecordManager(getContext()); + disposables = new CompositeDisposable(); } @NonNull protected abstract HistoryEntryAdapter createAdapter(); + protected abstract Single> insert(final Collection entries); + + protected abstract Single delete(final Collection entries); + + @NonNull + protected abstract Flowable> getAll(); + @Override public void onResume() { super.onResume(); - mHistoryDataSource.getAll() - .toObservable() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getHistoryListConsumer()); - boolean newEnabled = isHistoryEnabled(); + + getAll().observeOn(AndroidSchedulers.mainThread()).subscribe(getHistorySubscriber()); + + final boolean newEnabled = isHistoryEnabled(); if (newEnabled != mHistoryIsEnabled) { onHistoryIsEnabledChanged(newEnabled); } } @NonNull - private Observer> getHistoryListConsumer() { - return new Observer>() { + private Subscriber> getHistorySubscriber() { + return new Subscriber>() { @Override - public void onSubscribe(@NonNull Disposable d) { + public void onSubscribe(Subscription s) { + if (historySubscription != null) historySubscription.cancel(); + historySubscription = s; + historySubscription.request(1); } @Override - public void onNext(@NonNull List historyEntries) { - if (!historyEntries.isEmpty()) { - mHistoryAdapter.setEntries(historyEntries); + public void onNext(List entries) { + if (!entries.isEmpty()) { + mHistoryAdapter.setEntries(entries); animateView(mEmptyHistoryView, false, 200); if (mRecyclerViewState != null) { @@ -169,11 +125,13 @@ public abstract class HistoryFragment extends BaseFragme mHistoryAdapter.clear(); showEmptyHistory(); } + + if (historySubscription != null) historySubscription.request(1); } @Override - public void onError(@NonNull Throwable e) { - // TODO: error handling like in (see e.g. subscription fragment) + public void onError(Throwable t) { + } @Override @@ -192,30 +150,48 @@ public abstract class HistoryFragment extends BaseFragme */ @MainThread public void onHistoryCleared() { - final Parcelable stateBeforeClear = mRecyclerView.getLayoutManager().onSaveInstanceState(); - final Collection itemsToDelete = new ArrayList<>(mHistoryAdapter.getItems()); - mHistoryEntryDeleteSubject.onNext(itemsToDelete); + if (getContext() == null) return; + + new AlertDialog.Builder(getContext()) + .setTitle(R.string.delete_all) + .setMessage(R.string.delete_all_history_prompt) + .setCancelable(true) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.delete_all, (dialog, i) -> clearHistory()) + .show(); + } + + protected void makeSnackbar(@StringRes final int text) { + if (getActivity() == null) return; View view = getActivity().findViewById(R.id.main_content); if (view == null) view = mRecyclerView.getRootView(); + Snackbar.make(view, text, Snackbar.LENGTH_LONG).show(); + } - if (!itemsToDelete.isEmpty()) { - Snackbar.make(view, R.string.history_cleared, 5 * 1000) - .setActionTextColor(Color.WHITE) - .setAction(R.string.undo, new View.OnClickListener() { - @Override - public void onClick(View v) { - mRecyclerViewState = stateBeforeClear; - mHistoryEntryInsertSubject.onNext(itemsToDelete); - } - }).show(); - } else { - Snackbar.make(view, R.string.history_cleared, Snackbar.LENGTH_LONG).show(); - } + private void clearHistory() { + final Collection itemsToDelete = new ArrayList<>(mHistoryAdapter.getItems()); + final Disposable deletion = delete(itemsToDelete) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + ignored -> Log.d(TAG, "Clear history deleted [" + + itemsToDelete.size() + "] items."), + error -> Log.e(TAG, "Clear history delete step failed", error) + ); + + final Disposable cleanUp = historyRecordManager.removeOrphanedRecords() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + ignored -> Log.d(TAG, "Clear history deleted orphaned stream records"), + error -> Log.e(TAG, "Clear history remove orphaned records failed", error) + ); + + disposables.addAll(deletion, cleanUp); + + makeSnackbar(R.string.history_cleared); mHistoryAdapter.clear(); showEmptyHistory(); - } private void showEmptyHistory() { @@ -227,18 +203,18 @@ public abstract class HistoryFragment extends BaseFragme @Nullable @CallSuper @Override - public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + public View onCreateView(@NonNull LayoutInflater inflater, + @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { View rootView = inflater.inflate(R.layout.fragment_history, container, false); mRecyclerView = rootView.findViewById(R.id.history_view); - RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false); + RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(getContext(), + LinearLayoutManager.VERTICAL, false); mRecyclerView.setLayoutManager(layoutManager); mHistoryAdapter = createAdapter(); mHistoryAdapter.setOnHistoryItemClickListener(this); mRecyclerView.setAdapter(mHistoryAdapter); - ItemTouchHelper itemTouchHelper = new ItemTouchHelper(mHistoryItemSwipeCallback); - itemTouchHelper.attachToRecyclerView(mRecyclerView); mDisabledView = rootView.findViewById(R.id.history_disabled_view); mEmptyHistoryView = rootView.findViewById(R.id.history_empty); @@ -256,11 +232,16 @@ public abstract class HistoryFragment extends BaseFragme @Override public void onDestroy() { super.onDestroy(); + + if (disposables != null) disposables.dispose(); + if (historySubscription != null) historySubscription.cancel(); + mSharedPreferences.unregisterOnSharedPreferenceChangeListener(mHistoryIsEnabledChangeListener); mSharedPreferences = null; mHistoryIsEnabledChangeListener = null; mHistoryIsEnabledKey = null; - mHistoryDataSource = null; + historySubscription = null; + disposables = null; } @Override @@ -290,15 +271,8 @@ public abstract class HistoryFragment extends BaseFragme } } - /** - * Creates a new history DAO - * - * @return the history DAO - */ - @NonNull - protected abstract HistoryDAO createHistoryDAO(); - - private class HistoryIsEnabledChangeListener implements SharedPreferences.OnSharedPreferenceChangeListener { + private class HistoryIsEnabledChangeListener + implements SharedPreferences.OnSharedPreferenceChangeListener { @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { if (key.equals(mHistoryIsEnabledKey)) { diff --git a/app/src/main/java/org/schabi/newpipe/history/HistoryRecordManager.java b/app/src/main/java/org/schabi/newpipe/history/HistoryRecordManager.java new file mode 100644 index 000000000..9d3ffaffe --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/history/HistoryRecordManager.java @@ -0,0 +1,191 @@ +package org.schabi.newpipe.history; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.support.annotation.NonNull; + +import org.schabi.newpipe.NewPipeDatabase; +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.AppDatabase; +import org.schabi.newpipe.database.history.dao.SearchHistoryDAO; +import org.schabi.newpipe.database.history.dao.StreamHistoryDAO; +import org.schabi.newpipe.database.history.model.SearchHistoryEntry; +import org.schabi.newpipe.database.history.model.StreamHistoryEntity; +import org.schabi.newpipe.database.history.model.StreamHistoryEntry; +import org.schabi.newpipe.database.stream.StreamStatisticsEntry; +import org.schabi.newpipe.database.stream.dao.StreamDAO; +import org.schabi.newpipe.database.stream.dao.StreamStateDAO; +import org.schabi.newpipe.database.stream.model.StreamEntity; +import org.schabi.newpipe.database.stream.model.StreamStateEntity; +import org.schabi.newpipe.extractor.stream.StreamInfo; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.List; + +import io.reactivex.Flowable; +import io.reactivex.Maybe; +import io.reactivex.Single; +import io.reactivex.schedulers.Schedulers; + +public class HistoryRecordManager { + + private final AppDatabase database; + private final StreamDAO streamTable; + private final StreamHistoryDAO streamHistoryTable; + private final SearchHistoryDAO searchHistoryTable; + private final StreamStateDAO streamStateTable; + private final SharedPreferences sharedPreferences; + private final String searchHistoryKey; + private final String streamHistoryKey; + + public HistoryRecordManager(final Context context) { + database = NewPipeDatabase.getInstance(context); + streamTable = database.streamDAO(); + streamHistoryTable = database.streamHistoryDAO(); + searchHistoryTable = database.searchHistoryDAO(); + streamStateTable = database.streamStateDAO(); + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + searchHistoryKey = context.getString(R.string.enable_search_history_key); + streamHistoryKey = context.getString(R.string.enable_watch_history_key); + } + + /////////////////////////////////////////////////////// + // Watch History + /////////////////////////////////////////////////////// + + public Maybe onViewed(final StreamInfo info) { + if (!isStreamHistoryEnabled()) return Maybe.empty(); + + final Date currentTime = new Date(); + return Maybe.fromCallable(() -> database.runInTransaction(() -> { + final long streamId = streamTable.upsert(new StreamEntity(info)); + StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(); + + if (latestEntry != null && latestEntry.getStreamUid() == streamId) { + streamHistoryTable.delete(latestEntry); + latestEntry.setAccessDate(currentTime); + latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1); + return streamHistoryTable.insert(latestEntry); + } else { + return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime)); + } + })).subscribeOn(Schedulers.io()); + } + + public Single deleteStreamHistory(final long streamId) { + return Single.fromCallable(() -> streamHistoryTable.deleteStreamHistory(streamId)) + .subscribeOn(Schedulers.io()); + } + + public Flowable> getStreamHistory() { + return streamHistoryTable.getHistory().subscribeOn(Schedulers.io()); + } + + public Flowable> getStreamStatistics() { + return streamHistoryTable.getStatistics().subscribeOn(Schedulers.io()); + } + + public Single> insertStreamHistory(final Collection entries) { + List entities = new ArrayList<>(entries.size()); + for (final StreamHistoryEntry entry : entries) { + entities.add(entry.toStreamHistoryEntity()); + } + return Single.fromCallable(() -> streamHistoryTable.insertAll(entities)) + .subscribeOn(Schedulers.io()); + } + + public Single deleteStreamHistory(final Collection entries) { + List entities = new ArrayList<>(entries.size()); + for (final StreamHistoryEntry entry : entries) { + entities.add(entry.toStreamHistoryEntity()); + } + return Single.fromCallable(() -> streamHistoryTable.delete(entities)) + .subscribeOn(Schedulers.io()); + } + + private boolean isStreamHistoryEnabled() { + return sharedPreferences.getBoolean(streamHistoryKey, false); + } + + /////////////////////////////////////////////////////// + // Search History + /////////////////////////////////////////////////////// + + public Single> insertSearches(final Collection entries) { + return Single.fromCallable(() -> searchHistoryTable.insertAll(entries)) + .subscribeOn(Schedulers.io()); + } + + public Single deleteSearches(final Collection entries) { + return Single.fromCallable(() -> searchHistoryTable.delete(entries)) + .subscribeOn(Schedulers.io()); + } + + public Flowable> getSearchHistory() { + return searchHistoryTable.getAll(); + } + + public Maybe onSearched(final int serviceId, final String search) { + if (!isSearchHistoryEnabled()) return Maybe.empty(); + + final Date currentTime = new Date(); + final SearchHistoryEntry newEntry = new SearchHistoryEntry(currentTime, serviceId, search); + + return Maybe.fromCallable(() -> database.runInTransaction(() -> { + SearchHistoryEntry latestEntry = searchHistoryTable.getLatestEntry(); + if (latestEntry != null && latestEntry.hasEqualValues(newEntry)) { + latestEntry.setCreationDate(currentTime); + return (long) searchHistoryTable.update(latestEntry); + } else { + return searchHistoryTable.insert(newEntry); + } + })).subscribeOn(Schedulers.io()); + } + + public Single deleteSearchHistory(final String search) { + return Single.fromCallable(() -> searchHistoryTable.deleteAllWhereQuery(search)) + .subscribeOn(Schedulers.io()); + } + + public Flowable> getRelatedSearches(final String query, + final int similarQueryLimit, + final int uniqueQueryLimit) { + return query.length() > 0 + ? searchHistoryTable.getSimilarEntries(query, similarQueryLimit) + : searchHistoryTable.getUniqueEntries(uniqueQueryLimit); + } + + private boolean isSearchHistoryEnabled() { + return sharedPreferences.getBoolean(searchHistoryKey, false); + } + + /////////////////////////////////////////////////////// + // Stream State History + /////////////////////////////////////////////////////// + + @SuppressWarnings("unused") + public Maybe loadStreamState(final StreamInfo info) { + return Maybe.fromCallable(() -> streamTable.upsert(new StreamEntity(info))) + .flatMap(streamId -> streamStateTable.getState(streamId).firstElement()) + .flatMap(states -> states.isEmpty() ? Maybe.empty() : Maybe.just(states.get(0))) + .subscribeOn(Schedulers.io()); + } + + public Maybe saveStreamState(@NonNull final StreamInfo info, final long progressTime) { + return Maybe.fromCallable(() -> database.runInTransaction(() -> { + final long streamId = streamTable.upsert(new StreamEntity(info)); + return streamStateTable.upsert(new StreamStateEntity(streamId, progressTime)); + })).subscribeOn(Schedulers.io()); + } + + /////////////////////////////////////////////////////// + // Utility + /////////////////////////////////////////////////////// + + public Single removeOrphanedRecords() { + return Single.fromCallable(streamTable::deleteOrphans).subscribeOn(Schedulers.io()); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/history/SearchHistoryFragment.java b/app/src/main/java/org/schabi/newpipe/history/SearchHistoryFragment.java index 91e2cecff..25098fac8 100644 --- a/app/src/main/java/org/schabi/newpipe/history/SearchHistoryFragment.java +++ b/app/src/main/java/org/schabi/newpipe/history/SearchHistoryFragment.java @@ -5,22 +5,30 @@ import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.StringRes; +import android.support.v7.app.AlertDialog; import android.support.v7.widget.RecyclerView; -import android.support.v7.widget.helper.ItemTouchHelper; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; -import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; -import org.schabi.newpipe.database.history.dao.HistoryDAO; import org.schabi.newpipe.database.history.model.SearchHistoryEntry; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; -public class SearchHistoryFragment extends HistoryFragment { +import java.util.Collection; +import java.util.Collections; +import java.util.List; - private static int allowedSwipeToDeleteDirections = ItemTouchHelper.RIGHT; +import io.reactivex.Flowable; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; + +public class SearchHistoryFragment extends HistoryFragment { @NonNull public static SearchHistoryFragment newInstance() { @@ -30,7 +38,6 @@ public class SearchHistoryFragment extends HistoryFragment { @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - historyItemSwipeCallback(allowedSwipeToDeleteDirections); } @NonNull @@ -39,38 +46,82 @@ public class SearchHistoryFragment extends HistoryFragment { return new SearchHistoryAdapter(getContext()); } + @Override + protected Single> insert(Collection entries) { + return historyRecordManager.insertSearches(entries); + } + + @Override + protected Single delete(Collection entries) { + return historyRecordManager.deleteSearches(entries); + } + + @NonNull + @Override + protected Flowable> getAll() { + return historyRecordManager.getSearchHistory(); + } + @StringRes @Override int getEnabledConfigKey() { return R.string.enable_search_history_key; } - @NonNull @Override - protected HistoryDAO createHistoryDAO() { - return NewPipeDatabase.getInstance().searchHistoryDAO(); + public void onHistoryItemClick(final SearchHistoryEntry historyItem) { + NavigationHelper.openSearch(getContext(), historyItem.getServiceId(), + historyItem.getSearch()); } @Override - public void onHistoryItemClick(SearchHistoryEntry historyItem) { - NavigationHelper.openSearch(getContext(), historyItem.getServiceId(), historyItem.getSearch()); + public void onHistoryItemLongClick(final SearchHistoryEntry item) { + if (activity == null) return; + + new AlertDialog.Builder(activity) + .setTitle(item.getSearch()) + .setMessage(R.string.delete_item_search_history) + .setCancelable(true) + .setNeutralButton(R.string.cancel, null) + .setPositiveButton(R.string.delete_one, (dialog, i) -> { + final Disposable onDelete = historyRecordManager + .deleteSearches(Collections.singleton(item)) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + ignored -> {/*successful*/}, + error -> Log.e(TAG, "Search history Delete One failed:", error) + ); + disposables.add(onDelete); + makeSnackbar(R.string.item_deleted); + }) + .setNegativeButton(R.string.delete_all, (dialog, i) -> { + final Disposable onDeleteAll = historyRecordManager + .deleteSearchHistory(item.getSearch()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + ignored -> {/*successful*/}, + error -> Log.e(TAG, "Search history Delete All failed:", error) + ); + disposables.add(onDeleteAll); + makeSnackbar(R.string.item_deleted); + }) + .show(); } private static class ViewHolder extends RecyclerView.ViewHolder { private final TextView search; - private final TextView time; + private final TextView info; public ViewHolder(View itemView) { super(itemView); search = itemView.findViewById(R.id.search); - time = itemView.findViewById(R.id.time); + info = itemView.findViewById(R.id.info); } } protected class SearchHistoryAdapter extends HistoryEntryAdapter { - - public SearchHistoryAdapter(Context context) { + SearchHistoryAdapter(Context context) { super(context); } @@ -84,7 +135,11 @@ public class SearchHistoryFragment extends HistoryFragment { @Override void onBindViewHolder(ViewHolder holder, SearchHistoryEntry entry, int position) { holder.search.setText(entry.getSearch()); - holder.time.setText(getFormattedDate(entry.getCreationDate())); + + final String info = Localization.concatenateStrings( + getFormattedDate(entry.getCreationDate()), + NewPipe.getNameOfService(entry.getServiceId())); + holder.info.setText(info); } } } diff --git a/app/src/main/java/org/schabi/newpipe/history/WatchHistoryFragment.java b/app/src/main/java/org/schabi/newpipe/history/WatchHistoryFragment.java new file mode 100644 index 000000000..4830ed33b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/history/WatchHistoryFragment.java @@ -0,0 +1,170 @@ +package org.schabi.newpipe.history; + + +import android.content.Context; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.StringRes; +import android.support.v7.app.AlertDialog; +import android.support.v7.widget.RecyclerView; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import com.nostra13.universalimageloader.core.ImageLoader; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.history.model.StreamHistoryEntry; +import org.schabi.newpipe.info_list.holder.StreamInfoItemHolder; +import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.NavigationHelper; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import io.reactivex.Flowable; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; + + +public class WatchHistoryFragment extends HistoryFragment { + + @NonNull + public static WatchHistoryFragment newInstance() { + return new WatchHistoryFragment(); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @StringRes + @Override + int getEnabledConfigKey() { + return R.string.enable_watch_history_key; + } + + @NonNull + @Override + protected StreamHistoryAdapter createAdapter() { + return new StreamHistoryAdapter(getContext()); + } + + @Override + protected Single> insert(Collection entries) { + return historyRecordManager.insertStreamHistory(entries); + } + + @Override + protected Single delete(Collection entries) { + return historyRecordManager.deleteStreamHistory(entries); + } + + @NonNull + @Override + protected Flowable> getAll() { + return historyRecordManager.getStreamHistory(); + } + + @Override + public void onHistoryItemClick(StreamHistoryEntry historyItem) { + NavigationHelper.openVideoDetail(getContext(), historyItem.serviceId, historyItem.url, + historyItem.title); + } + + @Override + public void onHistoryItemLongClick(StreamHistoryEntry item) { + new AlertDialog.Builder(activity) + .setTitle(item.title) + .setMessage(R.string.delete_stream_history_prompt) + .setCancelable(true) + .setNeutralButton(R.string.cancel, null) + .setPositiveButton(R.string.delete_one, (dialog, i) -> { + final Disposable onDelete = historyRecordManager + .deleteStreamHistory(Collections.singleton(item)) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + ignored -> {/*successful*/}, + error -> Log.e(TAG, "Watch history Delete One failed:", error) + ); + disposables.add(onDelete); + makeSnackbar(R.string.item_deleted); + }) + .setNegativeButton(R.string.delete_all, (dialog, i) -> { + final Disposable onDeleteAll = historyRecordManager + .deleteStreamHistory(item.streamId) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + ignored -> {/*successful*/}, + error -> Log.e(TAG, "Watch history Delete All failed:", error) + ); + disposables.add(onDeleteAll); + makeSnackbar(R.string.item_deleted); + }) + .show(); + } + + private static class StreamHistoryAdapter extends HistoryEntryAdapter { + + StreamHistoryAdapter(Context context) { + super(context); + } + + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + View itemView = inflater.inflate(R.layout.list_stream_item, parent, false); + return new ViewHolder(itemView); + } + + @Override + public void onViewRecycled(ViewHolder holder) { + holder.itemView.setOnClickListener(null); + ImageLoader.getInstance() + .cancelDisplayTask(holder.thumbnailView); + } + + @Override + void onBindViewHolder(ViewHolder holder, StreamHistoryEntry entry, int position) { + final String formattedDate = getFormattedDate(entry.accessDate); + final String info; + if (entry.repeatCount > 1) { + info = Localization.concatenateStrings(formattedDate, + getFormattedViewString(entry.repeatCount)); + } else { + info = formattedDate; + } + + holder.info.setText(info); + holder.streamTitle.setText(entry.title); + holder.uploader.setText(entry.uploader); + holder.duration.setText(Localization.getDurationString(entry.duration)); + ImageLoader.getInstance().displayImage(entry.thumbnailUrl, holder.thumbnailView, + StreamInfoItemHolder.DISPLAY_THUMBNAIL_OPTIONS); + } + } + + private static class ViewHolder extends RecyclerView.ViewHolder { + private final TextView info; + private final TextView streamTitle; + private final ImageView thumbnailView; + private final TextView uploader; + private final TextView duration; + + public ViewHolder(View itemView) { + super(itemView); + thumbnailView = itemView.findViewById(R.id.itemThumbnailView); + info = itemView.findViewById(R.id.itemAdditionalDetails); + streamTitle = itemView.findViewById(R.id.itemVideoTitleView); + uploader = itemView.findViewById(R.id.itemUploaderView); + duration = itemView.findViewById(R.id.itemDurationView); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/history/WatchedHistoryFragment.java b/app/src/main/java/org/schabi/newpipe/history/WatchedHistoryFragment.java deleted file mode 100644 index d898bf353..000000000 --- a/app/src/main/java/org/schabi/newpipe/history/WatchedHistoryFragment.java +++ /dev/null @@ -1,116 +0,0 @@ -package org.schabi.newpipe.history; - - -import android.content.Context; -import android.os.Bundle; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.annotation.StringRes; -import android.support.v7.widget.RecyclerView; -import android.support.v7.widget.helper.ItemTouchHelper; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import com.nostra13.universalimageloader.core.ImageLoader; - -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.history.dao.HistoryDAO; -import org.schabi.newpipe.database.history.model.WatchHistoryEntry; -import org.schabi.newpipe.info_list.holder.StreamInfoItemHolder; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; - - -public class WatchedHistoryFragment extends HistoryFragment { - - private static int allowedSwipeToDeleteDirections = ItemTouchHelper.LEFT; - - @NonNull - public static WatchedHistoryFragment newInstance() { - return new WatchedHistoryFragment(); - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - historyItemSwipeCallback(allowedSwipeToDeleteDirections); - } - - @StringRes - @Override - int getEnabledConfigKey() { - return R.string.enable_watch_history_key; - } - - @NonNull - @Override - protected WatchedHistoryAdapter createAdapter() { - return new WatchedHistoryAdapter(getContext()); - } - - @NonNull - @Override - protected HistoryDAO createHistoryDAO() { - return NewPipeDatabase.getInstance().watchHistoryDAO(); - } - - @Override - public void onHistoryItemClick(WatchHistoryEntry historyItem) { - NavigationHelper.openVideoDetail(getContext(), - historyItem.getServiceId(), - historyItem.getUrl(), - historyItem.getTitle()); - } - - private static class WatchedHistoryAdapter extends HistoryEntryAdapter { - - public WatchedHistoryAdapter(Context context) { - super(context); - } - - @Override - public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - LayoutInflater inflater = LayoutInflater.from(parent.getContext()); - View itemView = inflater.inflate(R.layout.list_stream_item, parent, false); - return new ViewHolder(itemView); - } - - @Override - public void onViewRecycled(ViewHolder holder) { - holder.itemView.setOnClickListener(null); - ImageLoader.getInstance() - .cancelDisplayTask(holder.thumbnailView); - } - - @Override - void onBindViewHolder(ViewHolder holder, WatchHistoryEntry entry, int position) { - holder.date.setText(getFormattedDate(entry.getCreationDate())); - holder.streamTitle.setText(entry.getTitle()); - holder.uploader.setText(entry.getUploader()); - holder.duration.setText(Localization.getDurationString(entry.getDuration())); - ImageLoader.getInstance() - .displayImage(entry.getThumbnailURL(), holder.thumbnailView, StreamInfoItemHolder.DISPLAY_THUMBNAIL_OPTIONS); - } - } - - private static class ViewHolder extends RecyclerView.ViewHolder { - private final TextView date; - private final TextView streamTitle; - private final ImageView thumbnailView; - private final TextView uploader; - private final TextView duration; - - public ViewHolder(View itemView) { - super(itemView); - thumbnailView = itemView.findViewById(R.id.itemThumbnailView); - date = itemView.findViewById(R.id.itemAdditionalDetails); - streamTitle = itemView.findViewById(R.id.itemVideoTitleView); - uploader = itemView.findViewById(R.id.itemUploaderView); - duration = itemView.findViewById(R.id.itemDurationView); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java index ab3d73149..218895983 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java @@ -16,8 +16,10 @@ import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder; import org.schabi.newpipe.info_list.holder.InfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder; +import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder; import org.schabi.newpipe.info_list.holder.StreamInfoItemHolder; import org.schabi.newpipe.info_list.holder.StreamMiniInfoItemHolder; +import org.schabi.newpipe.util.OnClickGesture; /* * Created by Christian Schabesberger on 26.09.16. @@ -42,17 +44,12 @@ import org.schabi.newpipe.info_list.holder.StreamMiniInfoItemHolder; public class InfoItemBuilder { private static final String TAG = InfoItemBuilder.class.toString(); - public interface OnInfoItemSelectedListener { - void selected(T selectedItem); - void held(T selectedItem); - } - private final Context context; private ImageLoader imageLoader = ImageLoader.getInstance(); - private OnInfoItemSelectedListener onStreamSelectedListener; - private OnInfoItemSelectedListener onChannelSelectedListener; - private OnInfoItemSelectedListener onPlaylistSelectedListener; + private OnClickGesture onStreamSelectedListener; + private OnClickGesture onChannelSelectedListener; + private OnClickGesture onPlaylistSelectedListener; public InfoItemBuilder(Context context) { this.context = context; @@ -75,7 +72,7 @@ public class InfoItemBuilder { case CHANNEL: return useMiniVariant ? new ChannelMiniInfoItemHolder(this, parent) : new ChannelInfoItemHolder(this, parent); case PLAYLIST: - return new PlaylistInfoItemHolder(this, parent); + return useMiniVariant ? new PlaylistMiniInfoItemHolder(this, parent) : new PlaylistInfoItemHolder(this, parent); default: Log.e(TAG, "Trollolo"); throw new RuntimeException("InfoType not expected = " + infoType.name()); @@ -90,27 +87,27 @@ public class InfoItemBuilder { return imageLoader; } - public OnInfoItemSelectedListener getOnStreamSelectedListener() { + public OnClickGesture getOnStreamSelectedListener() { return onStreamSelectedListener; } - public void setOnStreamSelectedListener(OnInfoItemSelectedListener listener) { + public void setOnStreamSelectedListener(OnClickGesture listener) { this.onStreamSelectedListener = listener; } - public OnInfoItemSelectedListener getOnChannelSelectedListener() { + public OnClickGesture getOnChannelSelectedListener() { return onChannelSelectedListener; } - public void setOnChannelSelectedListener(OnInfoItemSelectedListener listener) { + public void setOnChannelSelectedListener(OnClickGesture listener) { this.onChannelSelectedListener = listener; } - public OnInfoItemSelectedListener getOnPlaylistSelectedListener() { + public OnClickGesture getOnPlaylistSelectedListener() { return onPlaylistSelectedListener; } - public void setOnPlaylistSelectedListener(OnInfoItemSelectedListener listener) { + public void setOnPlaylistSelectedListener(OnClickGesture listener) { this.onPlaylistSelectedListener = listener; } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemDialog.java index cdb2191e5..88aa76887 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemDialog.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemDialog.java @@ -19,7 +19,7 @@ public class InfoItemDialog { @NonNull final StreamInfoItem info, @NonNull final String[] commands, @NonNull final DialogInterface.OnClickListener actions) { - this(activity, commands, actions, info.getName(), info.uploader_name); + this(activity, commands, actions, info.getName(), info.getUploaderName()); } public InfoItemDialog(@NonNull final Activity activity, @@ -28,8 +28,7 @@ public class InfoItemDialog { @NonNull final String title, @Nullable final String additionalDetail) { - final LayoutInflater inflater = activity.getLayoutInflater(); - final View bannerView = inflater.inflate(R.layout.dialog_title, null); + final View bannerView = View.inflate(activity, R.layout.dialog_title, null); bannerView.setSelected(true); TextView titleView = bannerView.findViewById(R.id.itemTitleView); diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java index 806b348d7..4b9914397 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java @@ -10,13 +10,14 @@ import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.info_list.InfoItemBuilder.OnInfoItemSelectedListener; import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder; import org.schabi.newpipe.info_list.holder.InfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder; +import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder; import org.schabi.newpipe.info_list.holder.StreamInfoItemHolder; import org.schabi.newpipe.info_list.holder.StreamMiniInfoItemHolder; +import org.schabi.newpipe.util.OnClickGesture; import java.util.ArrayList; import java.util.List; @@ -52,6 +53,7 @@ public class InfoListAdapter extends RecyclerView.Adapter(); } - public void setOnStreamSelectedListener(OnInfoItemSelectedListener listener) { + public void setOnStreamSelectedListener(OnClickGesture listener) { infoItemBuilder.setOnStreamSelectedListener(listener); } - public void setOnChannelSelectedListener(OnInfoItemSelectedListener listener) { + public void setOnChannelSelectedListener(OnClickGesture listener) { infoItemBuilder.setOnChannelSelectedListener(listener); } - public void setOnPlaylistSelectedListener(OnInfoItemSelectedListener listener) { + public void setOnPlaylistSelectedListener(OnClickGesture listener) { infoItemBuilder.setOnPlaylistSelectedListener(listener); } @@ -200,14 +202,14 @@ public class InfoListAdapter extends RecyclerView.Adapter { + if (itemBuilder.getOnPlaylistSelectedListener() != null) { + itemBuilder.getOnPlaylistSelectedListener().selected(item); + } + }); + + itemView.setLongClickable(true); + itemView.setOnLongClickListener(view -> { + if (itemBuilder.getOnPlaylistSelectedListener() != null) { + itemBuilder.getOnPlaylistSelectedListener().held(item); + } + return true; + }); + } + + /** + * Display options for playlist thumbnails + */ + public static final DisplayImageOptions DISPLAY_THUMBNAIL_OPTIONS = + new DisplayImageOptions.Builder() + .cloneFrom(BASE_DISPLAY_IMAGE_OPTIONS) + .showImageOnLoading(R.drawable.dummy_thumbnail_playlist) + .showImageForEmptyUri(R.drawable.dummy_thumbnail_playlist) + .showImageOnFail(R.drawable.dummy_thumbnail_playlist) + .build(); +} diff --git a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java index 55a73d484..7558f1375 100644 --- a/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/BasePlayer.java @@ -63,6 +63,7 @@ import com.nostra13.universalimageloader.core.listener.SimpleImageLoadingListene import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.history.HistoryRecordManager; import org.schabi.newpipe.player.helper.AudioReactor; import org.schabi.newpipe.player.helper.CacheFactory; import org.schabi.newpipe.player.helper.LoadController; @@ -77,9 +78,8 @@ import java.util.concurrent.TimeUnit; import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; import io.reactivex.disposables.Disposable; -import io.reactivex.functions.Consumer; -import io.reactivex.functions.Predicate; import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; @@ -147,6 +147,9 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen protected DefaultExtractorsFactory extractorsFactory; protected Disposable progressUpdateReactor; + protected CompositeDisposable databaseUpdateReactor; + + protected HistoryRecordManager recordManager; //////////////////////////////////////////////////////////////////////////*/ @@ -172,6 +175,10 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen public void initPlayer() { if (DEBUG) Log.d(TAG, "initPlayer() called with: context = [" + context + "]"); + if (recordManager == null) recordManager = new HistoryRecordManager(context); + if (databaseUpdateReactor != null) databaseUpdateReactor.dispose(); + databaseUpdateReactor = new CompositeDisposable(); + final DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter(); final AdaptiveTrackSelection.Factory trackSelectionFactory = new AdaptiveTrackSelection.Factory(bandwidthMeter); final LoadControl loadControl = new LoadController(context); @@ -193,18 +200,8 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen private Disposable getProgressReactor() { return Observable.interval(PROGRESS_LOOP_INTERVAL, TimeUnit.MILLISECONDS) .observeOn(AndroidSchedulers.mainThread()) - .filter(new Predicate() { - @Override - public boolean test(Long aLong) throws Exception { - return isProgressLoopRunning(); - } - }) - .subscribe(new Consumer() { - @Override - public void accept(Long aLong) throws Exception { - triggerProgressUpdate(); - } - }); + .filter(ignored -> isProgressLoopRunning()) + .subscribe(ignored -> triggerProgressUpdate()); } public void handleIntent(Intent intent) { @@ -281,6 +278,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen if (playQueue != null) playQueue.dispose(); if (playbackManager != null) playbackManager.dispose(); if (audioReactor != null) audioReactor.abandonAudioFocus(); + if (databaseUpdateReactor != null) databaseUpdateReactor.dispose(); } public void destroy() { @@ -291,6 +289,7 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen trackSelector = null; simpleExoPlayer = null; + recordManager = null; } public MediaSource buildMediaSource(String url, String overrideExtension) { @@ -582,6 +581,8 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen errorToast = null; } + savePlaybackState(); + switch (error.type) { case ExoPlaybackException.TYPE_SOURCE: if (simpleExoPlayer.getCurrentPosition() < @@ -612,7 +613,8 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen // If the user selects a new track, then the discontinuity occurs after the index is changed. // Therefore, the only source that causes a discrepancy would be gapless transition, // which can only offset the current track by +1. - if (newWindowIndex == playQueue.getIndex() + 1) { + if (newWindowIndex == playQueue.getIndex() + 1 || + (newWindowIndex == 0 && playQueue.getIndex() == playQueue.size() - 1)) { playQueue.offsetIndex(+1); } playbackManager.load(); @@ -668,10 +670,17 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen "], queue index=[" + playQueue.getIndex() + "]"); } else if (simpleExoPlayer.getCurrentWindowIndex() != currentSourceIndex || !isPlaying()) { final long startPos = info != null ? info.start_position : 0; - if (DEBUG) Log.d(TAG, "Rewinding to correct window: " + currentSourceIndex + " at: " + getTimeString((int)startPos)); + if (DEBUG) Log.d(TAG, "Rewinding to correct window: " + currentSourceIndex + + " at: " + getTimeString((int)startPos)); simpleExoPlayer.seekTo(currentSourceIndex, startPos); } + // TODO: update exoplayer to 2.6.x in order to register view count on repeated streams + databaseUpdateReactor.add(recordManager.onViewed(currentInfo).onErrorComplete() + .subscribe( + ignored -> {/* successful */}, + error -> Log.e(TAG, "Player onViewed() failure: ", error) + )); initThumbnail(info == null ? item.getThumbnailUrl() : info.thumbnail_url); } @@ -755,6 +764,8 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen if (simpleExoPlayer == null || playQueue == null) return; if (DEBUG) Log.d(TAG, "onPlayPrevious() called"); + savePlaybackState(); + /* If current playback has run for PLAY_PREV_ACTIVATION_LIMIT milliseconds, restart current track. * Also restart the track if the current track is the first in a queue.*/ if (simpleExoPlayer.getCurrentPosition() > PLAY_PREV_ACTIVATION_LIMIT || playQueue.getIndex() == 0) { @@ -769,6 +780,8 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen if (playQueue == null) return; if (DEBUG) Log.d(TAG, "onPlayNext() called"); + savePlaybackState(); + playQueue.offsetIndex(+1); } @@ -830,6 +843,27 @@ public abstract class BasePlayer implements Player.EventListener, PlaybackListen ); } + protected void savePlaybackState(final StreamInfo info, final long progress) { + if (context == null || info == null || databaseUpdateReactor == null) return; + final Disposable stateSaver = recordManager.saveStreamState(info, progress) + .observeOn(AndroidSchedulers.mainThread()) + .onErrorComplete() + .subscribe( + ignored -> {/* successful */}, + error -> Log.e(TAG, "savePlaybackState() failure: ", error) + ); + databaseUpdateReactor.add(stateSaver); + } + + private void savePlaybackState() { + if (simpleExoPlayer == null || currentInfo == null) return; + + if (simpleExoPlayer.getCurrentPosition() > RECOVERY_SKIP_THRESHOLD && + simpleExoPlayer.getCurrentPosition() < + simpleExoPlayer.getDuration() - RECOVERY_SKIP_THRESHOLD) { + savePlaybackState(currentInfo, simpleExoPlayer.getCurrentPosition()); + } + } /*////////////////////////////////////////////////////////////////////////// // Getters and Setters //////////////////////////////////////////////////////////////////////////*/ diff --git a/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java b/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java index 4165dc087..5518357a8 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java @@ -29,6 +29,7 @@ import com.google.android.exoplayer2.Player; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; +import org.schabi.newpipe.fragments.local.dialog.PlaylistAppendDialog; import org.schabi.newpipe.player.event.PlayerEventListener; import org.schabi.newpipe.playlist.PlayQueueItem; import org.schabi.newpipe.playlist.PlayQueueItemBuilder; @@ -149,8 +150,8 @@ public abstract class ServicePlayerActivity extends AppCompatActivity case android.R.id.home: finish(); return true; - case R.id.action_history: - NavigationHelper.openHistory(this); + case R.id.action_append_playlist: + appendToPlaylist(); return true; case R.id.action_settings: NavigationHelper.openSettings(this); @@ -185,6 +186,14 @@ public abstract class ServicePlayerActivity extends AppCompatActivity null ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); } + + private void appendToPlaylist() { + if (this.player != null && this.player.getPlayQueue() != null) { + PlaylistAppendDialog.fromPlayQueueItems(this.player.getPlayQueue().getStreams()) + .show(getSupportFragmentManager(), getTag()); + } + } + //////////////////////////////////////////////////////////////////////////// // Service Connection //////////////////////////////////////////////////////////////////////////// diff --git a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItem.java b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItem.java index 9b14e8f03..f8e7b8655 100644 --- a/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItem.java +++ b/app/src/main/java/org/schabi/newpipe/playlist/PlayQueueItem.java @@ -5,6 +5,7 @@ import android.support.annotation.Nullable; 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.util.ExtractorHelper; import java.io.Serializable; @@ -23,6 +24,7 @@ public class PlayQueueItem implements Serializable { final private long duration; final private String thumbnailUrl; final private String uploader; + final private StreamType streamType; private long recoveryPosition; private Throwable error; @@ -30,22 +32,26 @@ public class PlayQueueItem implements Serializable { private transient Single stream; PlayQueueItem(@NonNull final StreamInfo info) { - this(info.getName(), info.getUrl(), info.getServiceId(), info.duration, info.thumbnail_url, info.uploader_name); + this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(), + info.getThumbnailUrl(), info.getUploaderName(), info.getStreamType()); this.stream = Single.just(info); } PlayQueueItem(@NonNull final StreamInfoItem item) { - this(item.getName(), item.getUrl(), item.getServiceId(), item.duration, item.thumbnail_url, item.uploader_name); + this(item.getName(), item.getUrl(), item.getServiceId(), item.getDuration(), + item.getThumbnailUrl(), item.getUploaderName(), item.getStreamType()); } private PlayQueueItem(final String name, final String url, final int serviceId, - final long duration, final String thumbnailUrl, final String uploader) { + final long duration, final String thumbnailUrl, final String uploader, + final StreamType streamType) { this.title = name; this.url = url; this.serviceId = serviceId; this.duration = duration; this.thumbnailUrl = thumbnailUrl; this.uploader = uploader; + this.streamType = streamType; this.recoveryPosition = RECOVERY_UNSET; } @@ -78,6 +84,10 @@ public class PlayQueueItem implements Serializable { return uploader; } + public StreamType getStreamType() { + return streamType; + } + public long getRecoveryPosition() { return recoveryPosition; } diff --git a/app/src/main/java/org/schabi/newpipe/playlist/SinglePlayQueue.java b/app/src/main/java/org/schabi/newpipe/playlist/SinglePlayQueue.java index ae74528eb..9c4d2fb39 100644 --- a/app/src/main/java/org/schabi/newpipe/playlist/SinglePlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/playlist/SinglePlayQueue.java @@ -3,19 +3,29 @@ package org.schabi.newpipe.playlist; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; public final class SinglePlayQueue extends PlayQueue { public SinglePlayQueue(final StreamInfoItem item) { - this(new PlayQueueItem(item)); + super(0, Collections.singletonList(new PlayQueueItem(item))); } public SinglePlayQueue(final StreamInfo info) { - this(new PlayQueueItem(info)); + super(0, Collections.singletonList(new PlayQueueItem(info))); } - private SinglePlayQueue(final PlayQueueItem playQueueItem) { - super(0, Collections.singletonList(playQueueItem)); + public SinglePlayQueue(final List items, final int index) { + super(index, playQueueItemsOf(items)); + } + + private static List playQueueItemsOf(List items) { + List playQueueItems = new ArrayList<>(items.size()); + for (final StreamInfoItem item : items) { + playQueueItems.add(new PlayQueueItem(item)); + } + return playQueueItems; } @Override diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.java b/app/src/main/java/org/schabi/newpipe/util/Localization.java index 43ebc1677..c1e5c9ed4 100644 --- a/app/src/main/java/org/schabi/newpipe/util/Localization.java +++ b/app/src/main/java/org/schabi/newpipe/util/Localization.java @@ -4,6 +4,7 @@ import android.content.Context; import android.content.SharedPreferences; import android.content.res.Resources; import android.preference.PreferenceManager; +import android.support.annotation.NonNull; import android.support.annotation.PluralsRes; import android.support.annotation.StringRes; import android.text.TextUtils; @@ -14,7 +15,9 @@ import java.text.DateFormat; import java.text.NumberFormat; import java.text.ParseException; import java.text.SimpleDateFormat; +import java.util.Arrays; import java.util.Date; +import java.util.List; import java.util.Locale; /* @@ -39,9 +42,33 @@ import java.util.Locale; public class Localization { + public final static String DOT_SEPARATOR = " • "; + private Localization() { } + @NonNull + public static String concatenateStrings(final String... strings) { + return concatenateStrings(Arrays.asList(strings)); + } + + @NonNull + public static String concatenateStrings(final List strings) { + if (strings.isEmpty()) return ""; + + final StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append(strings.get(0)); + + for (int i = 1; i < strings.size(); i++) { + final String string = strings.get(i); + if (!TextUtils.isEmpty(string)) { + stringBuilder.append(DOT_SEPARATOR).append(strings.get(i)); + } + } + + return stringBuilder.toString(); + } + public static Locale getPreferredLocale(Context context) { SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index 51cdd4cfe..2039dcc8e 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -33,6 +33,9 @@ import org.schabi.newpipe.fragments.list.feed.FeedFragment; import org.schabi.newpipe.fragments.list.kiosk.KioskFragment; import org.schabi.newpipe.fragments.list.playlist.PlaylistFragment; import org.schabi.newpipe.fragments.list.search.SearchFragment; +import org.schabi.newpipe.fragments.local.bookmark.LocalPlaylistFragment; +import org.schabi.newpipe.fragments.local.bookmark.MostPlayedFragment; +import org.schabi.newpipe.fragments.local.bookmark.LastPlayedFragment; import org.schabi.newpipe.history.HistoryActivity; import org.schabi.newpipe.player.BackgroundPlayer; import org.schabi.newpipe.player.BackgroundPlayerActivity; @@ -322,6 +325,30 @@ public class NavigationHelper { .commit(); } + public static void openLocalPlaylistFragment(FragmentManager fragmentManager, long playlistId, String name) { + if (name == null) name = ""; + fragmentManager.beginTransaction() + .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + .replace(R.id.fragment_holder, LocalPlaylistFragment.getInstance(playlistId, name)) + .addToBackStack(null) + .commit(); + } + + public static void openLastPlayedFragment(FragmentManager fragmentManager) { + fragmentManager.beginTransaction() + .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + .replace(R.id.fragment_holder, new LastPlayedFragment()) + .addToBackStack(null) + .commit(); + } + + public static void openMostPlayedFragment(FragmentManager fragmentManager) { + fragmentManager.beginTransaction() + .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) + .replace(R.id.fragment_holder, new MostPlayedFragment()) + .addToBackStack(null) + .commit(); + } /*////////////////////////////////////////////////////////////////////////// // Through Intents //////////////////////////////////////////////////////////////////////////*/ diff --git a/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.java b/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.java new file mode 100644 index 000000000..01416b279 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.java @@ -0,0 +1,16 @@ +package org.schabi.newpipe.util; + +import android.support.v7.widget.RecyclerView; + +public abstract class OnClickGesture { + + public abstract void selected(T selectedItem); + + public void held(T selectedItem) { + // Optional gesture + } + + public void drag(T selectedItem, RecyclerView.ViewHolder viewHolder) { + // Optional gesture + } +} diff --git a/app/src/main/res/drawable-hdpi/ic_bookmark_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_bookmark_black_24dp.png new file mode 100644 index 000000000..7ad39da3a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_bookmark_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_bookmark_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_bookmark_white_24dp.png new file mode 100644 index 000000000..9de15c51a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_bookmark_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_playlist_add_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_playlist_add_black_24dp.png new file mode 100644 index 000000000..731b42590 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_playlist_add_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_playlist_add_check_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_playlist_add_check_black_24dp.png new file mode 100644 index 000000000..92448842b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_playlist_add_check_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_playlist_add_check_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_playlist_add_check_white_24dp.png new file mode 100644 index 000000000..bd23b9c48 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_playlist_add_check_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_playlist_add_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_playlist_add_white_24dp.png new file mode 100644 index 000000000..4fb76e178 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_playlist_add_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_bookmark_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_bookmark_black_24dp.png new file mode 100644 index 000000000..0a10c2494 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_bookmark_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_bookmark_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_bookmark_white_24dp.png new file mode 100644 index 000000000..84f16627d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_bookmark_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_playlist_add_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_playlist_add_black_24dp.png new file mode 100644 index 000000000..d7a7514a8 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_playlist_add_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_playlist_add_check_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_playlist_add_check_black_24dp.png new file mode 100644 index 000000000..416490774 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_playlist_add_check_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_playlist_add_check_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_playlist_add_check_white_24dp.png new file mode 100644 index 000000000..0e35fe739 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_playlist_add_check_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_playlist_add_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_playlist_add_white_24dp.png new file mode 100644 index 000000000..73c981285 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_playlist_add_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_bookmark_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_bookmark_black_24dp.png new file mode 100644 index 000000000..5d71bf213 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_bookmark_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_bookmark_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_bookmark_white_24dp.png new file mode 100644 index 000000000..872349cca Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_bookmark_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_playlist_add_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_playlist_add_black_24dp.png new file mode 100644 index 000000000..dc4ebe9f3 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_playlist_add_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_playlist_add_check_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_playlist_add_check_black_24dp.png new file mode 100644 index 000000000..24855e94f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_playlist_add_check_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_playlist_add_check_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_playlist_add_check_white_24dp.png new file mode 100644 index 000000000..a94c5d035 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_playlist_add_check_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_playlist_add_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_playlist_add_white_24dp.png new file mode 100644 index 000000000..52ccba0b2 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_playlist_add_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_bookmark_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_bookmark_black_24dp.png new file mode 100644 index 000000000..2189be346 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_bookmark_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_bookmark_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_bookmark_white_24dp.png new file mode 100644 index 000000000..3faff90bb Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_bookmark_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_playlist_add_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_playlist_add_black_24dp.png new file mode 100644 index 000000000..af0bae3f0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_playlist_add_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_playlist_add_check_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_playlist_add_check_black_24dp.png new file mode 100644 index 000000000..ac03e19ab Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_playlist_add_check_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_playlist_add_check_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_playlist_add_check_white_24dp.png new file mode 100644 index 000000000..290088718 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_playlist_add_check_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_playlist_add_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_playlist_add_white_24dp.png new file mode 100644 index 000000000..3f652366d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_playlist_add_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_bookmark_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_bookmark_black_24dp.png new file mode 100644 index 000000000..2b90acd74 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_bookmark_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_bookmark_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_bookmark_white_24dp.png new file mode 100644 index 000000000..370cf8af5 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_bookmark_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_black_24dp.png new file mode 100644 index 000000000..46020a7e0 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_check_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_check_black_24dp.png new file mode 100644 index 000000000..068c596a3 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_check_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_check_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_check_white_24dp.png new file mode 100644 index 000000000..767d066de Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_check_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_white_24dp.png new file mode 100644 index 000000000..70e74e4a2 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_playlist_add_white_24dp.png differ diff --git a/app/src/main/res/layout/bookmark_header.xml b/app/src/main/res/layout/bookmark_header.xml new file mode 100644 index 000000000..8ca5c1228 --- /dev/null +++ b/app/src/main/res/layout/bookmark_header.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_playlist_name.xml b/app/src/main/res/layout/dialog_playlist_name.xml new file mode 100644 index 000000000..2dfab228b --- /dev/null +++ b/app/src/main/res/layout/dialog_playlist_name.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_playlists.xml b/app/src/main/res/layout/dialog_playlists.xml new file mode 100644 index 000000000..c08aa315e --- /dev/null +++ b/app/src/main/res/layout/dialog_playlists.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_bookmarks.xml b/app/src/main/res/layout/fragment_bookmarks.xml new file mode 100644 index 000000000..56e13225f --- /dev/null +++ b/app/src/main/res/layout/fragment_bookmarks.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_feed.xml b/app/src/main/res/layout/fragment_feed.xml index 0868d8233..d45060440 100644 --- a/app/src/main/res/layout/fragment_feed.xml +++ b/app/src/main/res/layout/fragment_feed.xml @@ -26,7 +26,7 @@ + + + + + + + + + + - + android:layout_height="match_parent" + android:layout_alignParentRight="true" + android:layout_alignParentEnd="true" + android:paddingLeft="6dp" + android:paddingRight="6dp"> + - + - + - + - + - + + + + + + + + + - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/subscription_feed_empty_view.xml b/app/src/main/res/layout/list_empty_view.xml similarity index 100% rename from app/src/main/res/layout/subscription_feed_empty_view.xml rename to app/src/main/res/layout/list_empty_view.xml diff --git a/app/src/main/res/layout/list_playlist_mini_item.xml b/app/src/main/res/layout/list_playlist_mini_item.xml new file mode 100644 index 000000000..7e94678cd --- /dev/null +++ b/app/src/main/res/layout/list_playlist_mini_item.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/list_stream_playlist_item.xml b/app/src/main/res/layout/list_stream_playlist_item.xml new file mode 100644 index 000000000..193b3fea4 --- /dev/null +++ b/app/src/main/res/layout/list_stream_playlist_item.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/local_playlist_header.xml b/app/src/main/res/layout/local_playlist_header.xml new file mode 100644 index 000000000..420da04ee --- /dev/null +++ b/app/src/main/res/layout/local_playlist_header.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/playlist_control.xml b/app/src/main/res/layout/playlist_control.xml index 821158bba..1135aedd5 100644 --- a/app/src/main/res/layout/playlist_control.xml +++ b/app/src/main/res/layout/playlist_control.xml @@ -1,14 +1,18 @@ - + android:visibility="invisible" + tools:visibility="visible"> - \ No newline at end of file + diff --git a/app/src/main/res/layout/subscription_header.xml b/app/src/main/res/layout/subscription_header.xml index 432be08b7..84a5a6216 100644 --- a/app/src/main/res/layout/subscription_header.xml +++ b/app/src/main/res/layout/subscription_header.xml @@ -6,7 +6,8 @@ android:layout_height="wrap_content" android:layout_marginBottom="12dp" android:background="?attr/selectableItemBackground" - android:clickable="true"> + android:clickable="true" + android:focusable="true"> + tools:context=".player.BackgroundPlayerActivity"> - + + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> + + \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 61bc5e520..794365a3d 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -26,6 +26,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b23e1db93..767bffa10 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,11 +32,13 @@ Main Subscriptions + Bookmarks What\'s New Background Popup + Add To Video download path Path to store downloaded videos in @@ -186,6 +188,7 @@ No results @string/no_videos Nothing Here But Crickets + Drag to reorder Cannot create download directory \'%1$s\' Created download directory \'%1$s\' @@ -224,8 +227,13 @@ Start Pause Play + Create Delete + Delete One + Delete All Checksum + Dismiss + Rename New mission @@ -303,6 +311,10 @@ History cleared Item deleted Do you want to delete this item from search history? + Do you want to delete this item from watch history? + Are you sure you want to delete all items from history? + Last Played + Most Played Content of main page @@ -353,6 +365,7 @@ YouTube SoundCloud + @string/preferred_player_settings_title Open with preferred player @@ -365,4 +378,21 @@ Getting info… "The requested content is loading" + + + Create New Playlist + Delete Playlist + Rename Playlist + Name + Add To Playlist + Set as Playlist Thumbnail + + Bookmark Playlist + Remove Bookmark + + Do you want to delete this playlist? + Playlist successfully created + Added to playlist + Playlist thumbnail changed + Failed to delete playlist diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index ee526ca41..b16958ae6 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -41,6 +41,9 @@ @drawable/ic_play_arrow_black_24dp @drawable/ic_whatshot_black_24dp @drawable/ic_channel_black_24dp + @drawable/ic_bookmark_black_24dp + @drawable/ic_playlist_add_black_24dp + @drawable/ic_playlist_add_check_black_24dp @color/light_separator_color @color/light_contrast_background_color @@ -88,6 +91,9 @@ @drawable/ic_play_arrow_white_24dp @drawable/ic_whatshot_white_24dp @drawable/ic_channel_white_24dp + @drawable/ic_bookmark_white_24dp + @drawable/ic_playlist_add_white_24dp + @drawable/ic_playlist_add_check_white_24dp @color/dark_separator_color @color/dark_contrast_background_color