diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 311e5248c..d08ae9051 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -68,7 +68,7 @@ The [checkStyle](https://github.com/checkstyle/checkstyle) plugin verifies that - Go to `File -> Settings -> Plugins`, search for `checkstyle` and install `CheckStyle-IDEA`. - Go to `File -> Settings -> Tools -> Checkstyle`. - Add NewPipe's configuration file by clicking the `+` in the right toolbar of the "Configuration File" list. -- Under the "Use a local Checkstyle file" bullet, click on `Browse` and pick the file named `checkstyle.xml` in the project's root folder. +- Under the "Use a local Checkstyle file" bullet, click on `Browse` and, enter `checkstyle` folder under the project's root path and pick the file named `checkstyle.xml`. - Enable "Store relative to project location" so that moving the directory around does not create issues. - Insert a description in the top bar, then click `Next` and then `Finish`. - Activate the configuration file you just added by enabling the checkbox on the left. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7dbfadc0b..306b8c2c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,7 @@ on: branches: - dev - master + - release/** paths-ignore: - 'README.md' - 'doc/**' @@ -31,7 +32,7 @@ jobs: build-and-test-jvm: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: gradle/wrapper-validation-action@v1 - name: create and checkout branch @@ -40,7 +41,7 @@ jobs: run: git checkout -B ${{ github.head_ref }} - name: set up JDK 11 - uses: actions/setup-java@v2 + uses: actions/setup-java@v3 with: java-version: 11 distribution: "temurin" @@ -50,7 +51,7 @@ jobs: run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint - name: Upload APK - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: app path: app/build/outputs/apk/debug/*.apk @@ -64,10 +65,10 @@ jobs: # api-level 19 is min sdk, but throws errors related to desugaring api-level: [ 21, 29 ] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: set up JDK 11 - uses: actions/setup-java@v2 + uses: actions/setup-java@v3 with: java-version: 11 distribution: "temurin" @@ -82,7 +83,7 @@ jobs: script: ./gradlew connectedCheck --stacktrace - name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553 - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 if: failure() with: name: android-test-report-api${{ matrix.api-level }} @@ -91,19 +92,19 @@ jobs: sonar: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Set up JDK 11 - uses: actions/setup-java@v2 + uses: actions/setup-java@v3 with: java-version: 11 # Sonar requires JDK 11 distribution: "temurin" cache: 'gradle' - name: Cache SonarCloud packages - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ~/.sonar/cache key: ${{ runner.os }}-sonar diff --git a/.github/workflows/image-minimizer.yml b/.github/workflows/image-minimizer.yml index 77b1faecf..c6ab6d5b3 100644 --- a/.github/workflows/image-minimizer.yml +++ b/.github/workflows/image-minimizer.yml @@ -11,9 +11,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - - uses: actions/setup-node@v2 + - uses: actions/setup-node@v3 with: node-version: 16 @@ -21,7 +21,7 @@ jobs: run: npm i probe-image-size@7.2.3 --ignore-scripts - name: Minimize simple images - uses: actions/github-script@v5 + uses: actions/github-script@v6 timeout-minutes: 3 with: script: | diff --git a/LICENSE b/LICENSE index 94a9ed024..f288702d2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,7 +1,7 @@ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 - Copyright (C) 2007 Free Software Foundation, Inc. + Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. @@ -645,7 +645,7 @@ the "copyright" line and a pointer to where the full notice is found. GNU General Public License for more details. You should have received a copy of the GNU General Public License - along with this program. If not, see . + along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. @@ -664,11 +664,11 @@ might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see -. +. The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read -. +. diff --git a/app/build.gradle b/app/build.gradle index f954d88e5..19c491a4d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,15 +9,15 @@ plugins { android { compileSdk 31 - buildToolsVersion '30.0.3' + buildToolsVersion '31.0.0' defaultConfig { applicationId "org.schabi.newpipe" resValue "string", "app_name", "NewPipe" minSdk 19 targetSdk 29 - versionCode 985 - versionName "0.22.2" + versionCode 986 + versionName "0.23.0" multiDexEnabled true @@ -98,13 +98,14 @@ android { } ext { - checkstyleVersion = '9.2.1' + checkstyleVersion = '10.0' androidxLifecycleVersion = '2.3.1' - androidxRoomVersion = '2.3.0' + androidxRoomVersion = '2.4.2' + androidxWorkVersion = '2.7.1' icepickVersion = '3.2.0' - exoPlayerVersion = '2.14.2' + exoPlayerVersion = '2.17.1' googleAutoServiceVersion = '1.0.1' groupieVersion = '2.10.0' markwonVersion = '4.6.2' @@ -121,7 +122,7 @@ configurations { } checkstyle { - getConfigDirectory().set(rootProject.file(".")) + getConfigDirectory().set(rootProject.file("checkstyle")) ignoreFailures false showViolations true toolVersion = checkstyleVersion @@ -189,11 +190,11 @@ dependencies { // name and the commit hash with the commit hash of the (pushed) commit you want to test // This works thanks to JitPack: https://jitpack.io/ implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751' - implementation 'com.github.TeamNewPipe:NewPipeExtractor:b77c72fb8826c3ffca0be5f96b066cca0a07b1c9' + implementation 'com.github.TeamNewPipe:NewPipeExtractor:ac1c22d81c65b7b0c5427f4e1989f5256d617f32' /** Checkstyle **/ checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}" - ktlint 'com.pinterest:ktlint:0.43.2' + ktlint 'com.pinterest:ktlint:0.44.0' /** Kotlin **/ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}" @@ -201,16 +202,16 @@ dependencies { /** AndroidX **/ implementation 'androidx.appcompat:appcompat:1.3.1' implementation 'androidx.cardview:cardview:1.0.0' - implementation 'androidx.constraintlayout:constraintlayout:2.1.2' + implementation 'androidx.constraintlayout:constraintlayout:2.1.3' implementation 'androidx.core:core-ktx:1.6.0' implementation 'androidx.documentfile:documentfile:1.0.1' implementation 'androidx.fragment:fragment-ktx:1.3.6' implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}" implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}" - implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0' - implementation 'androidx.media:media:1.4.3' + implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' + implementation 'androidx.media:media:1.5.0' implementation 'androidx.multidex:multidex:2.0.1' - implementation 'androidx.preference:preference:1.1.1' + implementation 'androidx.preference:preference:1.2.0' implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation "androidx.room:room-runtime:${androidxRoomVersion}" implementation "androidx.room:room-rxjava3:${androidxRoomVersion}" @@ -220,7 +221,10 @@ dependencies { // https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01 implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' implementation 'androidx.webkit:webkit:1.4.0' - implementation 'com.google.android.material:material:1.4.0' + implementation 'com.google.android.material:material:1.5.0' + implementation "androidx.work:work-runtime:${androidxWorkVersion}" + implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}" + implementation "androidx.work:work-rxjava3:${androidxWorkVersion}" /** Third-party libraries **/ // Instance state boilerplate elimination @@ -246,8 +250,6 @@ dependencies { implementation "com.github.lisawray.groupie:groupie:${groupieVersion}" implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}" - // Circular ImageView - implementation "de.hdodenhof:circleimageview:3.1.0" // Image loading //noinspection GradleDependency --> 2.8 is the last version, not 2.71828! implementation "com.squareup.picasso:picasso:2.8" diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 53a9ecd5a..4a54d8992 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -51,3 +51,6 @@ private void writeObject(java.io.ObjectOutputStream); private void readObject(java.io.ObjectInputStream); } + +# for some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml) +-keep class org.schabi.newpipe.settings.notifications.** { *; } diff --git a/app/schemas/org.schabi.newpipe.database.AppDatabase/5.json b/app/schemas/org.schabi.newpipe.database.AppDatabase/5.json new file mode 100644 index 000000000..9a1c62995 --- /dev/null +++ b/app/schemas/org.schabi.newpipe.database.AppDatabase/5.json @@ -0,0 +1,719 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "096731b513bb71dd44517639f4a2c1e3", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatar_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subscriberCount", + "columnName": "subscriber_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "notificationMode", + "columnName": "notification_mode", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_subscriptions_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationDate", + "columnName": "creation_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "search", + "columnName": "search", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_search_history_search", + "unique": false, + "columnNames": [ + "search" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "streams", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "streamType", + "columnName": "stream_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploader_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "viewCount", + "columnName": "view_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "textualUploadDate", + "columnName": "textual_upload_date", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadDate", + "columnName": "upload_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isUploadDateApproximation", + "columnName": "is_upload_date_approximation", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_streams_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "stream_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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 )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessDate", + "columnName": "access_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repeatCount", + "columnName": "repeat_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "stream_id", + "access_date" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_stream_history_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "stream_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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 )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressMillis", + "columnName": "progress_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "stream_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_playlists_name", + "unique": false, + "columnNames": [ + "name" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "playlist_stream_join", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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)", + "fields": [ + { + "fieldPath": "playlistUid", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "join_index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "playlist_id", + "join_index" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_playlist_stream_join_playlist_id_join_index", + "unique": true, + "columnNames": [ + "playlist_id", + "join_index" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)" + }, + { + "name": "index_playlist_stream_join_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "remote_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "streamCount", + "columnName": "stream_count", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_remote_playlists_name", + "unique": false, + "columnNames": [ + "name" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_remote_playlists_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "feed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "streamId", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "stream_id", + "subscription_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_feed_subscription_id", + "unique": false, + "columnNames": [ + "subscription_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "feed_group", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_feed_group_sort_order", + "unique": false, + "columnNames": [ + "sort_order" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "feed_group_subscription_join", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "feedGroupId", + "columnName": "group_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "group_id", + "subscription_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_feed_group_subscription_join_subscription_id", + "unique": false, + "columnNames": [ + "subscription_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" + } + ], + "foreignKeys": [ + { + "table": "feed_group", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "group_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "feed_last_updated", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "subscription_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '096731b513bb71dd44517639f4a2c1e3')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt b/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt new file mode 100644 index 000000000..28dea13e9 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt @@ -0,0 +1,130 @@ +package org.schabi.newpipe.database + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.schabi.newpipe.extractor.stream.StreamType + +@RunWith(AndroidJUnit4::class) +class DatabaseMigrationTest { + companion object { + private const val DEFAULT_SERVICE_ID = 0 + private const val DEFAULT_URL = "https://www.youtube.com/watch?v=cDphUib5iG4" + private const val DEFAULT_TITLE = "Test Title" + private val DEFAULT_TYPE = StreamType.VIDEO_STREAM + private const val DEFAULT_DURATION = 480L + private const val DEFAULT_UPLOADER_NAME = "Uploader Test" + private const val DEFAULT_THUMBNAIL = "https://example.com/example.jpg" + + private const val DEFAULT_SECOND_SERVICE_ID = 0 + private const val DEFAULT_SECOND_URL = "https://www.youtube.com/watch?v=ncQU6iBn5Fc" + } + + @get:Rule + val testHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java.canonicalName, FrameworkSQLiteOpenHelperFactory() + ) + + @Test + fun migrateDatabaseFrom2to3() { + val databaseInV2 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_2) + + databaseInV2.run { + insert( + "streams", SQLiteDatabase.CONFLICT_FAIL, + ContentValues().apply { + put("service_id", DEFAULT_SERVICE_ID) + put("url", DEFAULT_URL) + put("title", DEFAULT_TITLE) + put("stream_type", DEFAULT_TYPE.name) + put("duration", DEFAULT_DURATION) + put("uploader", DEFAULT_UPLOADER_NAME) + put("thumbnail_url", DEFAULT_THUMBNAIL) + } + ) + insert( + "streams", SQLiteDatabase.CONFLICT_FAIL, + ContentValues().apply { + put("service_id", DEFAULT_SECOND_SERVICE_ID) + put("url", DEFAULT_SECOND_URL) + } + ) + insert( + "streams", SQLiteDatabase.CONFLICT_FAIL, + ContentValues().apply { + put("service_id", DEFAULT_SERVICE_ID) + } + ) + close() + } + + testHelper.runMigrationsAndValidate( + AppDatabase.DATABASE_NAME, Migrations.DB_VER_3, + true, Migrations.MIGRATION_2_3 + ) + + testHelper.runMigrationsAndValidate( + AppDatabase.DATABASE_NAME, Migrations.DB_VER_4, + true, Migrations.MIGRATION_3_4 + ) + + testHelper.runMigrationsAndValidate( + AppDatabase.DATABASE_NAME, Migrations.DB_VER_5, + true, Migrations.MIGRATION_4_5 + ) + + val migratedDatabaseV3 = getMigratedDatabase() + val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst() + + // Only expect 2, the one with the null url will be ignored + assertEquals(2, listFromDB.size) + + val streamFromMigratedDatabase = listFromDB[0] + assertEquals(DEFAULT_SERVICE_ID, streamFromMigratedDatabase.serviceId) + assertEquals(DEFAULT_URL, streamFromMigratedDatabase.url) + assertEquals(DEFAULT_TITLE, streamFromMigratedDatabase.title) + assertEquals(DEFAULT_TYPE, streamFromMigratedDatabase.streamType) + assertEquals(DEFAULT_DURATION, streamFromMigratedDatabase.duration) + assertEquals(DEFAULT_UPLOADER_NAME, streamFromMigratedDatabase.uploader) + assertEquals(DEFAULT_THUMBNAIL, streamFromMigratedDatabase.thumbnailUrl) + assertNull(streamFromMigratedDatabase.viewCount) + assertNull(streamFromMigratedDatabase.textualUploadDate) + assertNull(streamFromMigratedDatabase.uploadDate) + assertNull(streamFromMigratedDatabase.isUploadDateApproximation) + + val secondStreamFromMigratedDatabase = listFromDB[1] + assertEquals(DEFAULT_SECOND_SERVICE_ID, secondStreamFromMigratedDatabase.serviceId) + assertEquals(DEFAULT_SECOND_URL, secondStreamFromMigratedDatabase.url) + assertEquals("", secondStreamFromMigratedDatabase.title) + // Should fallback to VIDEO_STREAM + assertEquals(StreamType.VIDEO_STREAM, secondStreamFromMigratedDatabase.streamType) + assertEquals(0, secondStreamFromMigratedDatabase.duration) + assertEquals("", secondStreamFromMigratedDatabase.uploader) + assertEquals("", secondStreamFromMigratedDatabase.thumbnailUrl) + assertNull(secondStreamFromMigratedDatabase.viewCount) + assertNull(secondStreamFromMigratedDatabase.textualUploadDate) + assertNull(secondStreamFromMigratedDatabase.uploadDate) + assertNull(secondStreamFromMigratedDatabase.isUploadDateApproximation) + } + + private fun getMigratedDatabase(): AppDatabase { + val database: AppDatabase = Room.databaseBuilder( + ApplicationProvider.getApplicationContext(), + AppDatabase::class.java, AppDatabase.DATABASE_NAME + ) + .build() + testHelper.closeWhenFinished(database) + return database + } +} diff --git a/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt b/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt new file mode 100644 index 000000000..a9aa40d82 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt @@ -0,0 +1,188 @@ +package org.schabi.newpipe.util + +import android.content.Context +import android.util.SparseArray +import android.view.View +import android.view.View.GONE +import android.view.View.INVISIBLE +import android.view.View.VISIBLE +import android.widget.Spinner +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.test.internal.runner.junit4.statement.UiThreadStatement +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.MediaFormat +import org.schabi.newpipe.extractor.stream.AudioStream +import org.schabi.newpipe.extractor.stream.Stream +import org.schabi.newpipe.extractor.stream.SubtitlesStream +import org.schabi.newpipe.extractor.stream.VideoStream + +@MediumTest +@RunWith(AndroidJUnit4::class) +class StreamItemAdapterTest { + private lateinit var context: Context + private lateinit var spinner: Spinner + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + UiThreadStatement.runOnUiThread { + spinner = Spinner(context) + } + } + + @Test + fun videoStreams_noSecondaryStream() { + val adapter = StreamItemAdapter( + context, + getVideoStreams(true, true, true, true), + null + ) + + spinner.adapter = adapter + assertIconVisibility(spinner, 0, VISIBLE, VISIBLE) + assertIconVisibility(spinner, 1, VISIBLE, VISIBLE) + assertIconVisibility(spinner, 2, VISIBLE, VISIBLE) + assertIconVisibility(spinner, 3, VISIBLE, VISIBLE) + } + + @Test + fun videoStreams_hasSecondaryStream() { + val adapter = StreamItemAdapter( + context, + getVideoStreams(false, true, false, true), + getAudioStreams(false, true, false, true) + ) + + spinner.adapter = adapter + assertIconVisibility(spinner, 0, GONE, GONE) + assertIconVisibility(spinner, 1, GONE, GONE) + assertIconVisibility(spinner, 2, GONE, GONE) + assertIconVisibility(spinner, 3, GONE, GONE) + } + + @Test + fun videoStreams_Mixed() { + val adapter = StreamItemAdapter( + context, + getVideoStreams(true, true, true, true, true, false, true, true), + getAudioStreams(false, true, false, false, false, true, true, true) + ) + + spinner.adapter = adapter + assertIconVisibility(spinner, 0, VISIBLE, VISIBLE) + assertIconVisibility(spinner, 1, GONE, INVISIBLE) + assertIconVisibility(spinner, 2, VISIBLE, VISIBLE) + assertIconVisibility(spinner, 3, VISIBLE, VISIBLE) + assertIconVisibility(spinner, 4, VISIBLE, VISIBLE) + assertIconVisibility(spinner, 5, GONE, INVISIBLE) + assertIconVisibility(spinner, 6, GONE, INVISIBLE) + assertIconVisibility(spinner, 7, GONE, INVISIBLE) + } + + @Test + fun subtitleStreams_noIcon() { + val adapter = StreamItemAdapter( + context, + StreamItemAdapter.StreamSizeWrapper( + (0 until 5).map { + SubtitlesStream(MediaFormat.SRT, "pt-BR", "https://example.com", false) + }, + context + ), + null + ) + spinner.adapter = adapter + for (i in 0 until spinner.count) { + assertIconVisibility(spinner, i, GONE, GONE) + } + } + + @Test + fun audioStreams_noIcon() { + val adapter = StreamItemAdapter( + context, + StreamItemAdapter.StreamSizeWrapper( + (0 until 5).map { AudioStream("https://example.com/$it", MediaFormat.OPUS, 192) }, + context + ), + null + ) + spinner.adapter = adapter + for (i in 0 until spinner.count) { + assertIconVisibility(spinner, i, GONE, GONE) + } + } + + /** + * @return a list of video streams, in which their video only property mirrors the provided + * [videoOnly] vararg. + */ + private fun getVideoStreams(vararg videoOnly: Boolean) = + StreamItemAdapter.StreamSizeWrapper( + videoOnly.map { + VideoStream("https://example.com", MediaFormat.MPEG_4, "720p", it) + }, + context + ) + + /** + * @return a list of audio streams, containing valid and null elements mirroring the provided + * [shouldBeValid] vararg. + */ + private fun getAudioStreams(vararg shouldBeValid: Boolean) = + getSecondaryStreamsFromList( + shouldBeValid.map { + if (it) AudioStream("https://example.com", MediaFormat.OPUS, 192) + else null + } + ) + + /** + * Checks whether the item at [position] in the [spinner] has the correct icon visibility when + * it is shown in normal mode (selected) and in dropdown mode (user is choosing one of a list). + */ + private fun assertIconVisibility( + spinner: Spinner, + position: Int, + normalVisibility: Int, + dropDownVisibility: Int + ) { + spinner.setSelection(position) + spinner.adapter.getView(position, null, spinner).run { + Assert.assertEquals( + "normal visibility (pos=[$position]) is not correct", + findViewById(R.id.wo_sound_icon).visibility, + normalVisibility, + ) + } + spinner.adapter.getDropDownView(position, null, spinner).run { + Assert.assertEquals( + "drop down visibility (pos=[$position]) is not correct", + findViewById(R.id.wo_sound_icon).visibility, + dropDownVisibility + ) + } + } + + /** + * Helper function that builds a secondary stream list. + */ + private fun getSecondaryStreamsFromList(streams: List) = + SparseArray?>(streams.size).apply { + streams.forEachIndexed { index, stream -> + val secondaryStreamHelper: SecondaryStreamHelper? = stream?.let { + SecondaryStreamHelper( + StreamItemAdapter.StreamSizeWrapper(streams, context), + it + ) + } + put(index, secondaryStreamHelper) + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 28cdbf020..f9c99819c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -381,9 +381,6 @@ - diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java index 54e0af8c6..6b02e21ca 100644 --- a/app/src/main/java/org/schabi/newpipe/App.java +++ b/app/src/main/java/org/schabi/newpipe/App.java @@ -27,7 +27,7 @@ import org.schabi.newpipe.util.StateSaver; import java.io.IOException; import java.io.InterruptedIOException; import java.net.SocketException; -import java.util.Arrays; +import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -213,37 +213,44 @@ public class App extends MultiDexApplication { private void initNotificationChannels() { // Keep the importance below DEFAULT to avoid making noise on every notification update for // the main and update channels - final NotificationChannelCompat mainChannel = new NotificationChannelCompat + final List notificationChannelCompats = new ArrayList<>(); + notificationChannelCompats.add(new NotificationChannelCompat .Builder(getString(R.string.notification_channel_id), NotificationManagerCompat.IMPORTANCE_LOW) .setName(getString(R.string.notification_channel_name)) .setDescription(getString(R.string.notification_channel_description)) - .build(); + .build()); - final NotificationChannelCompat appUpdateChannel = new NotificationChannelCompat + notificationChannelCompats.add(new NotificationChannelCompat .Builder(getString(R.string.app_update_notification_channel_id), NotificationManagerCompat.IMPORTANCE_LOW) .setName(getString(R.string.app_update_notification_channel_name)) .setDescription(getString(R.string.app_update_notification_channel_description)) - .build(); + .build()); - final NotificationChannelCompat hashChannel = new NotificationChannelCompat + notificationChannelCompats.add(new NotificationChannelCompat .Builder(getString(R.string.hash_channel_id), NotificationManagerCompat.IMPORTANCE_HIGH) .setName(getString(R.string.hash_channel_name)) .setDescription(getString(R.string.hash_channel_description)) - .build(); + .build()); - final NotificationChannelCompat errorReportChannel = new NotificationChannelCompat + notificationChannelCompats.add(new NotificationChannelCompat .Builder(getString(R.string.error_report_channel_id), NotificationManagerCompat.IMPORTANCE_LOW) .setName(getString(R.string.error_report_channel_name)) .setDescription(getString(R.string.error_report_channel_description)) - .build(); + .build()); + + notificationChannelCompats.add(new NotificationChannelCompat + .Builder(getString(R.string.streams_notification_channel_id), + NotificationManagerCompat.IMPORTANCE_DEFAULT) + .setName(getString(R.string.streams_notification_channel_name)) + .setDescription(getString(R.string.streams_notification_channel_description)) + .build()); final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this); - notificationManager.createNotificationChannelsCompat(Arrays.asList(mainChannel, - appUpdateChannel, hashChannel, errorReportChannel)); + notificationManager.createNotificationChannelsCompat(notificationChannelCompats); } protected boolean isDisposedRxExceptionsReported() { diff --git a/app/src/main/java/org/schabi/newpipe/CheckForNewAppVersion.java b/app/src/main/java/org/schabi/newpipe/CheckForNewAppVersion.java deleted file mode 100644 index 122660d64..000000000 --- a/app/src/main/java/org/schabi/newpipe/CheckForNewAppVersion.java +++ /dev/null @@ -1,264 +0,0 @@ -package org.schabi.newpipe; - -import android.app.Application; -import android.app.IntentService; -import android.app.PendingIntent; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.pm.Signature; -import android.net.Uri; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import androidx.core.content.pm.PackageInfoCompat; -import androidx.preference.PreferenceManager; - -import com.grack.nanojson.JsonObject; -import com.grack.nanojson.JsonParser; -import com.grack.nanojson.JsonParserException; - -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.downloader.Response; -import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.cert.CertificateEncodingException; -import java.security.cert.CertificateException; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import java.util.List; - -public final class CheckForNewAppVersion extends IntentService { - public CheckForNewAppVersion() { - super("CheckForNewAppVersion"); - } - - private static final boolean DEBUG = MainActivity.DEBUG; - private static final String TAG = CheckForNewAppVersion.class.getSimpleName(); - - // Public key of the certificate that is used in NewPipe release versions - private static final String RELEASE_CERT_PUBLIC_KEY_SHA1 - = "B0:2E:90:7C:1C:D6:FC:57:C3:35:F0:88:D0:8F:50:5F:94:E4:D2:15"; - private static final String NEWPIPE_API_URL = "https://newpipe.net/api/data.json"; - - /** - * Method to get the APK's SHA1 key. See https://stackoverflow.com/questions/9293019/#22506133. - * - * @param application The application - * @return String with the APK's SHA1 fingerprint in hexadecimal - */ - @NonNull - private static String getCertificateSHA1Fingerprint(@NonNull final Application application) { - final List signatures; - try { - signatures = PackageInfoCompat.getSignatures(application.getPackageManager(), - application.getPackageName()); - } catch (final PackageManager.NameNotFoundException e) { - ErrorUtil.createNotification(application, new ErrorInfo(e, - UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not find package info")); - return ""; - } - if (signatures.isEmpty()) { - return ""; - } - - final X509Certificate c; - try { - final byte[] cert = signatures.get(0).toByteArray(); - final InputStream input = new ByteArrayInputStream(cert); - final CertificateFactory cf = CertificateFactory.getInstance("X509"); - c = (X509Certificate) cf.generateCertificate(input); - } catch (final CertificateException e) { - ErrorUtil.createNotification(application, new ErrorInfo(e, - UserAction.CHECK_FOR_NEW_APP_VERSION, "Certificate error")); - return ""; - } - - try { - final MessageDigest md = MessageDigest.getInstance("SHA1"); - final byte[] publicKey = md.digest(c.getEncoded()); - return byte2HexFormatted(publicKey); - } catch (NoSuchAlgorithmException | CertificateEncodingException e) { - ErrorUtil.createNotification(application, new ErrorInfo(e, - UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not retrieve SHA1 key")); - return ""; - } - } - - private static String byte2HexFormatted(final byte[] arr) { - final StringBuilder str = new StringBuilder(arr.length * 2); - - for (int i = 0; i < arr.length; i++) { - String h = Integer.toHexString(arr[i]); - final int l = h.length(); - if (l == 1) { - h = "0" + h; - } - if (l > 2) { - h = h.substring(l - 2, l); - } - str.append(h.toUpperCase()); - if (i < (arr.length - 1)) { - str.append(':'); - } - } - return str.toString(); - } - - /** - * Method to compare the current and latest available app version. - * If a newer version is available, we show the update notification. - * - * @param application The application - * @param versionName Name of new version - * @param apkLocationUrl Url with the new apk - * @param versionCode Code of new version - */ - private static void compareAppVersionAndShowNotification(@NonNull final Application application, - final String versionName, - final String apkLocationUrl, - final int versionCode) { - if (BuildConfig.VERSION_CODE >= versionCode) { - return; - } - - // A pending intent to open the apk location url in the browser. - final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkLocationUrl)); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - final PendingIntent pendingIntent - = PendingIntent.getActivity(application, 0, intent, 0); - - final String channelId = application - .getString(R.string.app_update_notification_channel_id); - final NotificationCompat.Builder notificationBuilder - = new NotificationCompat.Builder(application, channelId) - .setSmallIcon(R.drawable.ic_newpipe_update) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setContentIntent(pendingIntent) - .setAutoCancel(true) - .setContentTitle(application - .getString(R.string.app_update_notification_content_title)) - .setContentText(application - .getString(R.string.app_update_notification_content_text) - + " " + versionName); - - final NotificationManagerCompat notificationManager - = NotificationManagerCompat.from(application); - notificationManager.notify(2000, notificationBuilder.build()); - } - - public static boolean isReleaseApk(@NonNull final App app) { - return getCertificateSHA1Fingerprint(app).equals(RELEASE_CERT_PUBLIC_KEY_SHA1); - } - - private void checkNewVersion() throws IOException, ReCaptchaException { - final App app = App.getApp(); - - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app); - final NewVersionManager manager = new NewVersionManager(); - - // Check if the current apk is a github one or not. - if (!isReleaseApk(app)) { - return; - } - - // Check if the last request has happened a certain time ago - // to reduce the number of API requests. - final long expiry = prefs.getLong(app.getString(R.string.update_expiry_key), 0); - if (!manager.isExpired(expiry)) { - return; - } - - // Make a network request to get latest NewPipe data. - final Response response = DownloaderImpl.getInstance().get(NEWPIPE_API_URL); - handleResponse(response, manager, prefs, app); - } - - private void handleResponse(@NonNull final Response response, - @NonNull final NewVersionManager manager, - @NonNull final SharedPreferences prefs, - @NonNull final App app) { - try { - // Store a timestamp which needs to be exceeded, - // before a new request to the API is made. - final long newExpiry = manager - .coerceExpiry(response.getHeader("expires")); - prefs.edit() - .putLong(app.getString(R.string.update_expiry_key), newExpiry) - .apply(); - } catch (final Exception e) { - if (DEBUG) { - Log.w(TAG, "Could not extract and save new expiry date", e); - } - } - - // Parse the json from the response. - try { - - final JsonObject githubStableObject = JsonParser.object() - .from(response.responseBody()).getObject("flavors") - .getObject("github").getObject("stable"); - - final String versionName = githubStableObject - .getString("version"); - final int versionCode = githubStableObject - .getInt("version_code"); - final String apkLocationUrl = githubStableObject - .getString("apk"); - - compareAppVersionAndShowNotification(app, versionName, - apkLocationUrl, versionCode); - } catch (final JsonParserException e) { - // Most likely something is wrong in data received from NEWPIPE_API_URL. - // Do not alarm user and fail silently. - if (DEBUG) { - Log.w(TAG, "Could not get NewPipe API: invalid json", e); - } - } - } - - /** - * Start a new service which - * checks if all conditions for performing a version check are met, - * fetches the API endpoint {@link #NEWPIPE_API_URL} containing info - * about the latest NewPipe version - * and displays a notification about ana available update. - *
- * Following conditions need to be met, before data is request from the server: - *
    - *
  • The app is signed with the correct signing key (by TeamNewPipe / schabi). - * If the signing key differs from the one used upstream, the update cannot be installed.
  • - *
  • The user enabled searching for and notifying about updates in the settings.
  • - *
  • The app did not recently check for updates. - * We do not want to make unnecessary connections and DOS our servers.
  • - *
- * Must not be executed when the app is in background. - */ - public static void startNewVersionCheckService() { - final Intent intent = new Intent(App.getApp().getApplicationContext(), - CheckForNewAppVersion.class); - App.getApp().startService(intent); - } - - @Override - protected void onHandleIntent(@Nullable final Intent intent) { - try { - checkNewVersion(); - } catch (final IOException e) { - Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e); - } catch (final ReCaptchaException e) { - Log.e(TAG, "ReCaptchaException should never happen here.", e); - } - - } -} diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index 95663ea0a..fcb9d9725 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -20,7 +20,6 @@ package org.schabi.newpipe; -import static org.schabi.newpipe.CheckForNewAppVersion.startNewVersionCheckService; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; import android.content.BroadcastReceiver; @@ -72,6 +71,7 @@ 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.local.feed.notifications.NotificationWorker; import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.event.OnKeyDownListener; import org.schabi.newpipe.player.helper.PlayerHolder; @@ -159,11 +159,14 @@ public class MainActivity extends AppCompatActivity { } catch (final Exception e) { ErrorUtil.showUiErrorSnackbar(this, "Setting up drawer", e); } - if (DeviceUtils.isTv(this)) { FocusOverlayView.setupFocusObserver(this); } openMiniPlayerUponPlayerStarted(); + + // Schedule worker for checking for new streams and creating corresponding notifications + // if this is enabled by the user. + NotificationWorker.initialize(this); } @Override @@ -174,10 +177,9 @@ public class MainActivity extends AppCompatActivity { final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app); if (prefs.getBoolean(app.getString(R.string.update_app_key), true)) { - // Start the service which is checking all conditions + // Start the worker which is checking all conditions // and eventually searching for a new version. - // The service searching for a new NewPipe version must not be started in background. - startNewVersionCheckService(); + NewVersionWorker.enqueueNewVersionCheckingWork(app); } } @@ -227,7 +229,7 @@ public class MainActivity extends AppCompatActivity { drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_tabs_group, kioskId, 0, KioskTranslator .getTranslatedKioskName(ks, this)) - .setIcon(KioskTranslator.getKioskIcon(ks, this)); + .setIcon(KioskTranslator.getKioskIcon(ks)); kioskId++; } @@ -719,7 +721,7 @@ public class MainActivity extends AppCompatActivity { if (toggle != null) { toggle.syncState(); toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> mainBinding.getRoot() - .openDrawer(GravityCompat.START)); + .open()); mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED); } } else { diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java index 36bd6ee0d..402d4648d 100644 --- a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java +++ b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java @@ -1,5 +1,11 @@ package org.schabi.newpipe; +import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME; +import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2; +import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3; +import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4; +import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5; + import android.content.Context; import android.database.Cursor; @@ -8,11 +14,6 @@ import androidx.room.Room; import org.schabi.newpipe.database.AppDatabase; -import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME; -import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2; -import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3; -import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4; - public final class NewPipeDatabase { private static volatile AppDatabase databaseInstance; @@ -23,7 +24,7 @@ public final class NewPipeDatabase { private static AppDatabase getDatabase(final Context context) { return Room .databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) .build(); } diff --git a/app/src/main/java/org/schabi/newpipe/NewVersionManager.kt b/app/src/main/java/org/schabi/newpipe/NewVersionManager.kt deleted file mode 100644 index 36de1ecfc..000000000 --- a/app/src/main/java/org/schabi/newpipe/NewVersionManager.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.schabi.newpipe - -import java.time.Instant -import java.time.ZonedDateTime -import java.time.format.DateTimeFormatter - -class NewVersionManager { - - fun isExpired(expiry: Long): Boolean { - return Instant.ofEpochSecond(expiry).isBefore(Instant.now()) - } - - /** - * Coerce expiry date time in between 6 hours and 72 hours from now - * - * @return Epoch second of expiry date time - */ - fun coerceExpiry(expiryString: String?): Long { - val now = ZonedDateTime.now() - return expiryString?.let { - - var expiry = ZonedDateTime.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(expiryString)) - expiry = maxOf(expiry, now.plusHours(6)) - expiry = minOf(expiry, now.plusHours(72)) - expiry.toEpochSecond() - } ?: now.plusHours(6).toEpochSecond() - } -} diff --git a/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt b/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt new file mode 100644 index 000000000..060114974 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt @@ -0,0 +1,163 @@ +package org.schabi.newpipe + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.preference.PreferenceManager +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkRequest +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.grack.nanojson.JsonParser +import com.grack.nanojson.JsonParserException +import org.schabi.newpipe.extractor.downloader.Response +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException +import org.schabi.newpipe.util.ReleaseVersionUtil.coerceUpdateCheckExpiry +import org.schabi.newpipe.util.ReleaseVersionUtil.isLastUpdateCheckExpired +import org.schabi.newpipe.util.ReleaseVersionUtil.isReleaseApk +import java.io.IOException + +class NewVersionWorker( + context: Context, + workerParams: WorkerParameters +) : Worker(context, workerParams) { + + /** + * Method to compare the current and latest available app version. + * If a newer version is available, we show the update notification. + * + * @param versionName Name of new version + * @param apkLocationUrl Url with the new apk + * @param versionCode Code of new version + */ + private fun compareAppVersionAndShowNotification( + versionName: String, + apkLocationUrl: String?, + versionCode: Int + ) { + if (BuildConfig.VERSION_CODE >= versionCode) { + return + } + val app = App.getApp() + + // A pending intent to open the apk location url in the browser. + val intent = Intent(Intent.ACTION_VIEW, apkLocationUrl?.toUri()) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val pendingIntent = PendingIntent.getActivity(app, 0, intent, 0) + val channelId = app.getString(R.string.app_update_notification_channel_id) + val notificationBuilder = NotificationCompat.Builder(app, channelId) + .setSmallIcon(R.drawable.ic_newpipe_update) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setContentTitle(app.getString(R.string.app_update_notification_content_title)) + .setContentText( + app.getString(R.string.app_update_notification_content_text) + + " " + versionName + ) + val notificationManager = NotificationManagerCompat.from(app) + notificationManager.notify(2000, notificationBuilder.build()) + } + + @Throws(IOException::class, ReCaptchaException::class) + private fun checkNewVersion() { + // Check if the current apk is a github one or not. + if (!isReleaseApk()) { + return + } + + val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) + // Check if the last request has happened a certain time ago + // to reduce the number of API requests. + val expiry = prefs.getLong(applicationContext.getString(R.string.update_expiry_key), 0) + if (!isLastUpdateCheckExpired(expiry)) { + return + } + + // Make a network request to get latest NewPipe data. + val response = DownloaderImpl.getInstance().get(NEWPIPE_API_URL) + handleResponse(response) + } + + private fun handleResponse(response: Response) { + val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) + try { + // Store a timestamp which needs to be exceeded, + // before a new request to the API is made. + val newExpiry = coerceUpdateCheckExpiry(response.getHeader("expires")) + prefs.edit { + putLong(applicationContext.getString(R.string.update_expiry_key), newExpiry) + } + } catch (e: Exception) { + if (DEBUG) { + Log.w(TAG, "Could not extract and save new expiry date", e) + } + } + + // Parse the json from the response. + try { + val githubStableObject = JsonParser.`object`() + .from(response.responseBody()).getObject("flavors") + .getObject("github").getObject("stable") + + val versionName = githubStableObject.getString("version") + val versionCode = githubStableObject.getInt("version_code") + val apkLocationUrl = githubStableObject.getString("apk") + compareAppVersionAndShowNotification(versionName, apkLocationUrl, versionCode) + } catch (e: JsonParserException) { + // Most likely something is wrong in data received from NEWPIPE_API_URL. + // Do not alarm user and fail silently. + if (DEBUG) { + Log.w(TAG, "Could not get NewPipe API: invalid json", e) + } + } + } + + override fun doWork(): Result { + try { + checkNewVersion() + } catch (e: IOException) { + Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e) + return Result.failure() + } catch (e: ReCaptchaException) { + Log.e(TAG, "ReCaptchaException should never happen here.", e) + return Result.failure() + } + return Result.success() + } + + companion object { + private val DEBUG = MainActivity.DEBUG + private val TAG = NewVersionWorker::class.java.simpleName + private const val NEWPIPE_API_URL = "https://newpipe.net/api/data.json" + + /** + * Start a new worker which + * checks if all conditions for performing a version check are met, + * fetches the API endpoint [.NEWPIPE_API_URL] containing info + * about the latest NewPipe version + * and displays a notification about ana available update. + *

+ * Following conditions need to be met, before data is request from the server: + * + * * The app is signed with the correct signing key (by TeamNewPipe / schabi). + * If the signing key differs from the one used upstream, the update cannot be installed. + * * The user enabled searching for and notifying about updates in the settings. + * * The app did not recently check for updates. + * We do not want to make unnecessary connections and DOS our servers. + * + */ + @JvmStatic + fun enqueueNewVersionCheckingWork(context: Context) { + val workRequest: WorkRequest = + OneTimeWorkRequest.Builder(NewVersionWorker::class.java).build() + WorkManager.getInstance(context).enqueue(workRequest) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java b/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java index fde006a60..c7604e512 100644 --- a/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java +++ b/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java @@ -14,7 +14,7 @@ import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.SaveUploaderUrlHelper; +import org.schabi.newpipe.util.SparseItemUtil; import java.util.Collections; @@ -62,7 +62,8 @@ public final class QueueItemMenuUtil { return true; case R.id.menu_item_channel_details: - SaveUploaderUrlHelper.saveUploaderUrlIfNeeded(context, item, + SparseItemUtil.fetchUploaderUrlIfSparse(context, item.getServiceId(), + item.getUrl(), item.getUploaderUrl(), // An intent must be used here. // Opening with FragmentManager transactions is not working, // as PlayQueueActivity doesn't use fragments. diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index 9d6e44f04..adef3c0e4 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -633,7 +633,7 @@ public class RouterActivity extends AppCompatActivity { .subscribe(result -> { final List sortedVideoStreams = ListHelper .getSortedStreamVideosList(this, result.getVideoStreams(), - result.getVideoOnlyStreams(), false); + result.getVideoOnlyStreams(), false, false); final int selectedVideoStreamIndex = ListHelper .getDefaultResolutionIndex(this, sortedVideoStreams); diff --git a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt index 1e5bd8799..50a3984e3 100644 --- a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt +++ b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt @@ -10,7 +10,6 @@ import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.viewpager2.adapter.FragmentStateAdapter -import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.R @@ -21,30 +20,28 @@ import org.schabi.newpipe.util.ThemeHelper import org.schabi.newpipe.util.external_communication.ShareUtils class AboutActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { Localization.assureCorrectAppLanguage(this) super.onCreate(savedInstanceState) ThemeHelper.setTheme(this) title = getString(R.string.title_activity_about) + val aboutBinding = ActivityAboutBinding.inflate(layoutInflater) setContentView(aboutBinding.root) setSupportActionBar(aboutBinding.aboutToolbar) - supportActionBar!!.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + // Create the adapter that will return a fragment for each of the three // primary sections of the activity. val mAboutStateAdapter = AboutStateAdapter(this) - // Set up the ViewPager with the sections adapter. aboutBinding.aboutViewPager2.adapter = mAboutStateAdapter TabLayoutMediator( aboutBinding.aboutTabLayout, aboutBinding.aboutViewPager2 - ) { tab: TabLayout.Tab, position: Int -> - when (position) { - POS_ABOUT -> tab.setText(R.string.tab_about) - POS_LICENSE -> tab.setText(R.string.tab_licenses) - else -> throw IllegalArgumentException("Unknown position for ViewPager2") - } + ) { tab, position -> + tab.setText(mAboutStateAdapter.getPageTitle(position)) }.attach() } @@ -75,13 +72,14 @@ class AboutActivity : AppCompatActivity() { container: ViewGroup?, savedInstanceState: Bundle? ): View { - val aboutBinding = FragmentAboutBinding.inflate(inflater, container, false) - aboutBinding.aboutAppVersion.text = BuildConfig.VERSION_NAME - aboutBinding.aboutGithubLink.openLink(R.string.github_url) - aboutBinding.aboutDonationLink.openLink(R.string.donation_url) - aboutBinding.aboutWebsiteLink.openLink(R.string.website_url) - aboutBinding.aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url) - return aboutBinding.root + FragmentAboutBinding.inflate(inflater, container, false).apply { + aboutAppVersion.text = BuildConfig.VERSION_NAME + aboutGithubLink.openLink(R.string.github_url) + aboutDonationLink.openLink(R.string.donation_url) + aboutWebsiteLink.openLink(R.string.website_url) + aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url) + return root + } } } @@ -90,17 +88,29 @@ class AboutActivity : AppCompatActivity() { * one of the sections/tabs/pages. */ private class AboutStateAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) { + private val posAbout = 0 + private val posLicense = 1 + private val totalCount = 2 + override fun createFragment(position: Int): Fragment { return when (position) { - POS_ABOUT -> AboutFragment() - POS_LICENSE -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS) + posAbout -> AboutFragment() + posLicense -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS) else -> throw IllegalArgumentException("Unknown position for ViewPager2") } } override fun getItemCount(): Int { // Show 2 total pages. - return TOTAL_COUNT + return totalCount + } + + fun getPageTitle(position: Int): Int { + return when (position) { + posAbout -> R.string.tab_about + posLicense -> R.string.tab_licenses + else -> throw IllegalArgumentException("Unknown position for ViewPager2") + } } } @@ -117,10 +127,6 @@ class AboutActivity : AppCompatActivity() { "AndroidX", "2005 - 2011", "The Android Open Source Project", "https://developer.android.com/jetpack", StandardLicenses.APACHE2 ), - SoftwareComponent( - "CircleImageView", "2014 - 2020", "Henning Dodenhof", - "https://github.com/hdodenhof/CircleImageView", StandardLicenses.APACHE2 - ), SoftwareComponent( "ExoPlayer", "2014 - 2020", "Google, Inc.", "https://github.com/google/ExoPlayer", StandardLicenses.APACHE2 @@ -191,8 +197,5 @@ class AboutActivity : AppCompatActivity() { "https://github.com/ByteHamster/SearchPreference", StandardLicenses.MIT ), ) - private const val POS_ABOUT = 0 - private const val POS_LICENSE = 1 - private const val TOTAL_COUNT = 2 } } diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt index a04de8abc..c1dd38389 100644 --- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt +++ b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt @@ -87,60 +87,50 @@ object LicenseFragmentHelper { return context.getString(color).substring(3) } - @JvmStatic fun showLicense(context: Context?, license: License): Disposable { + return showLicense(context, license) { alertDialog -> + alertDialog.setPositiveButton(R.string.ok) { dialog, _ -> + dialog.dismiss() + } + } + } + + fun showLicense(context: Context?, component: SoftwareComponent): Disposable { + return showLicense(context, component.license) { alertDialog -> + alertDialog.setPositiveButton(R.string.dismiss) { dialog, _ -> + dialog.dismiss() + } + alertDialog.setNeutralButton(R.string.open_website_license) { _, _ -> + ShareUtils.openUrlInBrowser(context!!, component.link) + } + } + } + + private fun showLicense( + context: Context?, + license: License, + block: (AlertDialog.Builder) -> Unit + ): Disposable { return if (context == null) { Disposable.empty() } else { Observable.fromCallable { getFormattedLicense(context, license) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe { formattedLicense: String -> + .subscribe { formattedLicense -> val webViewData = Base64.encodeToString( - formattedLicense - .toByteArray(StandardCharsets.UTF_8), - Base64.NO_PADDING + formattedLicense.toByteArray(StandardCharsets.UTF_8), Base64.NO_PADDING ) val webView = WebView(context) webView.loadData(webViewData, "text/html; charset=UTF-8", "base64") - val alert = AlertDialog.Builder(context) - alert.setTitle(license.name) - alert.setView(webView) - Localization.assureCorrectAppLanguage(context) - alert.setNegativeButton( - context.getString(R.string.ok) - ) { dialog, _ -> dialog.dismiss() } - alert.show() - } - } - } - @JvmStatic - fun showLicense(context: Context?, component: SoftwareComponent): Disposable { - return if (context == null) { - Disposable.empty() - } else { - Observable.fromCallable { getFormattedLicense(context, component.license) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { formattedLicense: String -> - val webViewData = Base64.encodeToString( - formattedLicense - .toByteArray(StandardCharsets.UTF_8), - Base64.NO_PADDING - ) - val webView = WebView(context) - webView.loadData(webViewData, "text/html; charset=UTF-8", "base64") - val alert = AlertDialog.Builder(context) - alert.setTitle(component.license.name) - alert.setView(webView) - Localization.assureCorrectAppLanguage(context) - alert.setPositiveButton( - R.string.dismiss - ) { dialog, _ -> dialog.dismiss() } - alert.setNeutralButton(R.string.open_website_license) { _, _ -> - ShareUtils.openUrlInBrowser(context, component.link) + + AlertDialog.Builder(context).apply { + setTitle(license.name) + setView(webView) + Localization.assureCorrectAppLanguage(context) + block(this) + show() } - alert.show() } } } 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 cf52d9453..28ddc8184 100644 --- a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java +++ b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.database; +import static org.schabi.newpipe.database.Migrations.DB_VER_5; + import androidx.room.Database; import androidx.room.RoomDatabase; import androidx.room.TypeConverters; @@ -27,8 +29,6 @@ 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_4; - @TypeConverters({Converters.class}) @Database( entities = { @@ -38,7 +38,7 @@ import static org.schabi.newpipe.database.Migrations.DB_VER_4; FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class, FeedLastUpdatedEntity.class }, - version = DB_VER_4 + version = DB_VER_5 ) public abstract class AppDatabase extends RoomDatabase { public static final String DATABASE_NAME = "newpipe.db"; diff --git a/app/src/main/java/org/schabi/newpipe/database/Migrations.java b/app/src/main/java/org/schabi/newpipe/database/Migrations.java index fdd38a824..7de08442c 100644 --- a/app/src/main/java/org/schabi/newpipe/database/Migrations.java +++ b/app/src/main/java/org/schabi/newpipe/database/Migrations.java @@ -22,6 +22,7 @@ public final class Migrations { public static final int DB_VER_2 = 2; public static final int DB_VER_3 = 3; public static final int DB_VER_4 = 4; + public static final int DB_VER_5 = 5; private static final String TAG = Migrations.class.getName(); public static final boolean DEBUG = MainActivity.DEBUG; @@ -179,5 +180,14 @@ public final class Migrations { } }; - private Migrations() { } + public static final Migration MIGRATION_4_5 = new Migration(DB_VER_4, DB_VER_5) { + @Override + public void migrate(@NonNull final SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` " + + "INTEGER NOT NULL DEFAULT 0"); + } + }; + + private Migrations() { + } } diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt index 72692a9f5..d573788a6 100644 --- a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt +++ b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt @@ -12,6 +12,7 @@ import org.schabi.newpipe.database.feed.model.FeedEntity import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity import org.schabi.newpipe.database.stream.StreamWithState import org.schabi.newpipe.database.stream.model.StreamStateEntity +import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.SubscriptionEntity import java.time.OffsetDateTime @@ -252,4 +253,21 @@ abstract class FeedDAO { """ ) abstract fun getAllOutdatedForGroup(groupId: Long, outdatedThreshold: OffsetDateTime): Flowable> + + @Query( + """ + SELECT s.* FROM subscriptions s + + LEFT JOIN feed_last_updated lu + ON s.uid = lu.subscription_id + + WHERE + (lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold) + AND s.notification_mode = :notificationMode + """ + ) + abstract fun getOutdatedWithNotificationMode( + outdatedThreshold: OffsetDateTime, + @NotificationMode notificationMode: Int + ): Flowable> } 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 index 0a765ed4e..150d4a8e5 100644 --- 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 @@ -3,6 +3,7 @@ package org.schabi.newpipe.database.history.dao; import androidx.annotation.Nullable; import androidx.room.Dao; import androidx.room.Query; +import androidx.room.RewriteQueriesToDropUnusedColumns; import org.schabi.newpipe.database.history.model.StreamHistoryEntity; import org.schabi.newpipe.database.history.model.StreamHistoryEntry; @@ -67,6 +68,7 @@ public abstract class StreamHistoryDAO implements HistoryDAO { + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") Flowable getMaximumIndexOf(long playlistId); + @RewriteQueriesToDropUnusedColumns @Transaction @Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN " // get ids of streams of the given playlist diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt index 7dc16e784..a22fd2bb9 100644 --- a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt +++ b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt @@ -39,6 +39,9 @@ abstract class StreamDAO : BasicDAO { @Insert(onConflict = OnConflictStrategy.IGNORE) internal abstract fun silentInsertAllInternal(streams: List): List + @Query("SELECT COUNT(*) != 0 FROM streams WHERE url = :url AND service_id = :serviceId") + internal abstract fun exists(serviceId: Int, url: String): Boolean + @Query( """ SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java b/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java new file mode 100644 index 000000000..07e0eb7d3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java @@ -0,0 +1,14 @@ +package org.schabi.newpipe.database.subscription; + +import androidx.annotation.IntDef; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@IntDef({NotificationMode.DISABLED, NotificationMode.ENABLED}) +@Retention(RetentionPolicy.SOURCE) +public @interface NotificationMode { + + int DISABLED = 0; + int ENABLED = 1; + //other values reserved for the future +} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt index 9798ec72d..47b6f4dd9 100644 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.RewriteQueriesToDropUnusedColumns import androidx.room.Transaction import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Maybe @@ -31,6 +32,7 @@ abstract class SubscriptionDAO : BasicDAO { ) abstract fun getSubscriptionsFiltered(filter: String): Flowable> + @RewriteQueriesToDropUnusedColumns @Query( """ SELECT * FROM subscriptions s @@ -47,6 +49,7 @@ abstract class SubscriptionDAO : BasicDAO { currentGroupId: Long ): Flowable> + @RewriteQueriesToDropUnusedColumns @Query( """ SELECT * FROM subscriptions s 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 1cf38dbca..0e4bda490 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 @@ -26,6 +26,7 @@ public class SubscriptionEntity { public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url"; public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count"; public static final String SUBSCRIPTION_DESCRIPTION = "description"; + public static final String SUBSCRIPTION_NOTIFICATION_MODE = "notification_mode"; @PrimaryKey(autoGenerate = true) private long uid = 0; @@ -48,6 +49,9 @@ public class SubscriptionEntity { @ColumnInfo(name = SUBSCRIPTION_DESCRIPTION) private String description; + @ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE) + private int notificationMode; + @Ignore public static SubscriptionEntity from(@NonNull final ChannelInfo info) { final SubscriptionEntity result = new SubscriptionEntity(); @@ -114,6 +118,15 @@ public class SubscriptionEntity { this.description = description; } + @NotificationMode + public int getNotificationMode() { + return notificationMode; + } + + public void setNotificationMode(@NotificationMode final int notificationMode) { + this.notificationMode = notificationMode; + } + @Ignore public void setData(final String n, final String au, final String d, final Long sc) { this.setName(n); diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java index 5c954ad64..f5c226908 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -61,6 +61,7 @@ import org.schabi.newpipe.util.FilenameUtils; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.SecondaryStreamHelper; +import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; import org.schabi.newpipe.util.StreamItemAdapter; import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper; import org.schabi.newpipe.util.ThemeHelper; @@ -151,7 +152,7 @@ public class DownloadDialog extends DialogFragment public static DownloadDialog newInstance(final Context context, final StreamInfo info) { final ArrayList streamsList = new ArrayList<>(ListHelper .getSortedStreamVideosList(context, info.getVideoStreams(), - info.getVideoOnlyStreams(), false)); + info.getVideoOnlyStreams(), false, false)); final int selectedStreamIndex = ListHelper.getDefaultResolutionIndex(context, streamsList); final DownloadDialog instance = newInstance(info); @@ -321,21 +322,15 @@ public class DownloadDialog extends DialogFragment final int threads = prefs.getInt(getString(R.string.default_download_threads), 3); dialogBinding.threadsCount.setText(String.valueOf(threads)); dialogBinding.threads.setProgress(threads - 1); - dialogBinding.threads.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + dialogBinding.threads.setOnSeekBarChangeListener(new SimpleOnSeekBarChangeListener() { @Override - public void onProgressChanged(final SeekBar seekbar, final int progress, + public void onProgressChanged(@NonNull final SeekBar seekbar, final int progress, final boolean fromUser) { final int newProgress = progress + 1; prefs.edit().putInt(getString(R.string.default_download_threads), newProgress) .apply(); dialogBinding.threadsCount.setText(String.valueOf(newProgress)); } - - @Override - public void onStartTrackingTouch(final SeekBar p1) { } - - @Override - public void onStopTrackingTouch(final SeekBar p1) { } }); fetchStreamsSize(); diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt index 3fd743c69..e4dd2e16d 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt @@ -1,7 +1,6 @@ package org.schabi.newpipe.error import android.app.Activity -import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent @@ -10,7 +9,7 @@ import android.os.Build import android.view.View import android.widget.Toast import androidx.core.app.NotificationCompat -import androidx.core.content.ContextCompat +import androidx.core.app.NotificationManagerCompat import androidx.fragment.app.Fragment import com.google.android.material.snackbar.Snackbar import org.schabi.newpipe.R @@ -105,13 +104,6 @@ class ErrorUtil { */ @JvmStatic fun createNotification(context: Context, errorInfo: ErrorInfo) { - val notificationManager = - ContextCompat.getSystemService(context, NotificationManager::class.java) - if (notificationManager == null) { - // this should never happen, but just in case open error activity - openActivity(context, errorInfo) - } - var pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { pendingIntentFlags = pendingIntentFlags or PendingIntent.FLAG_IMMUTABLE @@ -122,7 +114,13 @@ class ErrorUtil { context, context.getString(R.string.error_report_channel_id) ) - .setSmallIcon(R.drawable.ic_bug_report) + .setSmallIcon( + // the vector drawable icon causes crashes on KitKat devices + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) + R.drawable.ic_bug_report + else + android.R.drawable.stat_notify_error + ) .setContentTitle(context.getString(R.string.error_report_notification_title)) .setContentText(context.getString(errorInfo.messageStringId)) .setAutoCancel(true) @@ -135,7 +133,8 @@ class ErrorUtil { ) ) - notificationManager!!.notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build()) + NotificationManagerCompat.from(context) + .notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build()) // since the notification is silent, also show a toast, otherwise the user is confused Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT) diff --git a/app/src/main/java/org/schabi/newpipe/error/UserAction.java b/app/src/main/java/org/schabi/newpipe/error/UserAction.java index e8dec9556..976173373 100644 --- a/app/src/main/java/org/schabi/newpipe/error/UserAction.java +++ b/app/src/main/java/org/schabi/newpipe/error/UserAction.java @@ -26,10 +26,11 @@ public enum UserAction { DOWNLOAD_OPEN_DIALOG("download open dialog"), DOWNLOAD_POSTPROCESSING("download post-processing"), DOWNLOAD_FAILED("download failed"), + NEW_STREAMS_NOTIFICATIONS("new streams notifications"), PREFERENCES_MIGRATION("migration of preferences"), SHARE_TO_NEWPIPE("share to newpipe"), - CHECK_FOR_NEW_APP_VERSION("check for new app version"); - + CHECK_FOR_NEW_APP_VERSION("check for new app version"), + OPEN_INFO_ITEM_DIALOG("open info item dialog"); private final String message; diff --git a/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java b/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java index cbd44566e..6b17803c4 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java @@ -1,5 +1,6 @@ package org.schabi.newpipe.fragments; +import androidx.annotation.NonNull; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.StaggeredGridLayoutManager; @@ -10,7 +11,7 @@ import androidx.recyclerview.widget.StaggeredGridLayoutManager; */ public abstract class OnScrollBelowItemsListener extends RecyclerView.OnScrollListener { @Override - public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) { + public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) { super.onScrolled(recyclerView, dx, dy); if (dy > 0) { int pastVisibleItems = 0; diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java index 2fe615764..5016a49f6 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.fragments.detail; +import androidx.annotation.NonNull; + import org.schabi.newpipe.player.playqueue.PlayQueue; import java.io.Serializable; @@ -46,6 +48,7 @@ class StackItem implements Serializable { return playQueue; } + @NonNull @Override public String toString() { return getServiceId() + ":" + getUrl() + " > " + getTitle(); 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 78f0bfffb..c57942aa5 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 @@ -43,7 +43,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.content.ContextCompat; import androidx.preference.PreferenceManager; -import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.PlaybackException; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.material.appbar.AppBarLayout; import com.google.android.material.bottomsheet.BottomSheetBehavior; @@ -1617,6 +1617,7 @@ public final class VideoDetailFragment activity, info.getVideoStreams(), info.getVideoOnlyStreams(), + false, false); selectedVideoStreamIndex = ListHelper .getDefaultResolutionIndex(activity, sortedVideoStreams); @@ -1883,9 +1884,8 @@ public final class VideoDetailFragment } @Override - public void onPlayerError(final ExoPlaybackException error) { - if (error.type == ExoPlaybackException.TYPE_SOURCE - || error.type == ExoPlaybackException.TYPE_UNEXPECTED) { + public void onPlayerError(final PlaybackException error, final boolean isCatchableException) { + if (!isCatchableException) { // Properly exit from fullscreen toggleFullscreenIfInFullscreenMode(); hideMainPlayerOnLoadingNewStream(); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java index 9309a8a49..ae704e88c 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java @@ -15,6 +15,7 @@ import androidx.appcompat.app.AlertDialog; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.PlaybackException; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.ListRadioIconItemBinding; @@ -28,6 +29,10 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.function.Supplier; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_DECODING_FAILED; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_UNSPECIFIED; + /** * Outsourced logic for crashing the player in the {@link VideoDetailFragment}. */ @@ -51,7 +56,8 @@ public final class VideoDetailPlayerCrasher { exceptionTypes.put( "Source", () -> ExoPlaybackException.createForSource( - new IOException(defaultMsg) + new IOException(defaultMsg), + ERROR_CODE_BEHIND_LIVE_WINDOW ) ); exceptionTypes.put( @@ -61,13 +67,16 @@ public final class VideoDetailPlayerCrasher { "Dummy renderer", 0, null, - C.FORMAT_HANDLED + C.FORMAT_HANDLED, + /*isRecoverable=*/false, + ERROR_CODE_DECODING_FAILED ) ); exceptionTypes.put( "Unexpected", () -> ExoPlaybackException.createForUnexpected( - new RuntimeException(defaultMsg) + new RuntimeException(defaultMsg), + ERROR_CODE_UNSPECIFIED ) ); exceptionTypes.put( @@ -139,7 +148,7 @@ public final class VideoDetailPlayerCrasher { /** * Note that this method does not crash the underlying exoplayer directly (it's not possible). - * It simply supplies a Exception to {@link Player#onPlayerError(ExoPlaybackException)}. + * It simply supplies a Exception to {@link Player#onPlayerError(PlaybackException)}. * @param player * @param exception */ 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 6ea0a8a0d..27e5a8571 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 @@ -1,6 +1,8 @@ package org.schabi.newpipe.fragments.list; -import android.app.Activity; +import static org.schabi.newpipe.ktx.ViewUtils.animate; +import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; + import android.content.Context; import android.content.SharedPreferences; import android.content.res.Configuration; @@ -25,29 +27,19 @@ import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; -import org.schabi.newpipe.info_list.InfoItemDialog; +import org.schabi.newpipe.info_list.dialog.InfoItemDialog; import org.schabi.newpipe.info_list.InfoListAdapter; -import org.schabi.newpipe.player.helper.PlayerHolder; -import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.StateSaver; -import org.schabi.newpipe.util.StreamDialogEntry; import org.schabi.newpipe.views.SuperScrollLayoutManager; -import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import java.util.Queue; import java.util.function.Supplier; -import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; -import static org.schabi.newpipe.ktx.ViewUtils.animate; -import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; - public abstract class BaseListFragment extends BaseStateFragment implements ListViewContract, StateSaver.WriteRead, SharedPreferences.OnSharedPreferenceChangeListener { @@ -268,11 +260,11 @@ public abstract class BaseListFragment extends BaseStateFragment @Override public void held(final StreamInfoItem selectedItem) { - showStreamDialog(selectedItem); + showInfoItemDialog(selectedItem); } }); - infoListAdapter.setOnChannelSelectedListener(new OnClickGesture() { + infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<>() { @Override public void selected(final ChannelInfoItem selectedItem) { try { @@ -288,7 +280,7 @@ public abstract class BaseListFragment extends BaseStateFragment } }); - infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture() { + infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture<>() { @Override public void selected(final PlaylistInfoItem selectedItem) { try { @@ -350,7 +342,8 @@ public abstract class BaseListFragment extends BaseStateFragment itemsList.clearOnScrollListeners(); itemsList.addOnScrollListener(new DefaultItemListOnScrolledDownListener() { @Override - public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) { + public void onScrolled(@NonNull final RecyclerView recyclerView, + final int dx, final int dy) { super.onScrolled(recyclerView, dx, dy); if (dy != 0) { @@ -409,55 +402,12 @@ public abstract class BaseListFragment extends BaseStateFragment } } - protected void showStreamDialog(final StreamInfoItem item) { - final Context context = getContext(); - final Activity activity = getActivity(); - if (context == null || context.getResources() == null || activity == null) { - return; + protected void showInfoItemDialog(final StreamInfoItem item) { + try { + new InfoItemDialog.Builder(getActivity(), getContext(), this, item).create().show(); + } catch (final IllegalArgumentException e) { + InfoItemDialog.Builder.reportErrorDuringInitialization(e, item); } - final List entries = new ArrayList<>(); - - if (PlayerHolder.getInstance().isPlayQueueReady()) { - entries.add(StreamDialogEntry.enqueue); - - if (PlayerHolder.getInstance().getQueueSize() > 1) { - entries.add(StreamDialogEntry.enqueue_next); - } - } - - if (item.getStreamType() == StreamType.AUDIO_STREAM) { - entries.addAll(Arrays.asList( - StreamDialogEntry.start_here_on_background, - StreamDialogEntry.append_playlist, - StreamDialogEntry.share - )); - } else { - entries.addAll(Arrays.asList( - StreamDialogEntry.start_here_on_background, - StreamDialogEntry.start_here_on_popup, - StreamDialogEntry.append_playlist, - StreamDialogEntry.share - )); - } - entries.add(StreamDialogEntry.open_in_browser); - if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) { - entries.add(StreamDialogEntry.play_with_kodi); - } - - // show "mark as watched" only when watch history is enabled - if (StreamDialogEntry.shouldAddMarkAsWatched(item.getStreamType(), context)) { - entries.add( - StreamDialogEntry.mark_as_watched - ); - } - if (!isNullOrEmpty(item.getUploaderUrl())) { - entries.add(StreamDialogEntry.show_channel_details); - } - - StreamDialogEntry.setEnabledEntries(entries); - - new InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context), - (dialog, which) -> StreamDialogEntry.clickOn(which, this, item)).show(); } /*////////////////////////////////////////////////////////////////////////// diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java index ebd586e35..35424437d 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java @@ -9,6 +9,7 @@ import androidx.annotation.NonNull; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.Page; @@ -27,8 +28,8 @@ import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; -public abstract class BaseListInfoFragment - extends BaseListFragment { +public abstract class BaseListInfoFragment> + extends BaseListFragment> { @State protected int serviceId = Constants.NO_SERVICE_ID; @State @@ -37,7 +38,7 @@ public abstract class BaseListInfoFragment protected String url; private final UserAction errorUserAction; - protected I currentInfo; + protected L currentInfo; protected Page currentNextPage; protected Disposable currentWorker; @@ -97,7 +98,7 @@ public abstract class BaseListInfoFragment @SuppressWarnings("unchecked") public void readFrom(@NonNull final Queue savedObjects) throws Exception { super.readFrom(savedObjects); - currentInfo = (I) savedObjects.poll(); + currentInfo = (L) savedObjects.poll(); currentNextPage = (Page) savedObjects.poll(); } @@ -124,7 +125,7 @@ public abstract class BaseListInfoFragment * @param forceLoad allow or disallow the result to come from the cache * @return Rx {@link Single} containing the {@link ListInfo} */ - protected abstract Single loadResult(boolean forceLoad); + protected abstract Single loadResult(boolean forceLoad); @Override public void startLoading(final boolean forceLoad) { @@ -140,7 +141,7 @@ public abstract class BaseListInfoFragment currentWorker = loadResult(forceLoad) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .subscribe((@NonNull I result) -> { + .subscribe((@NonNull L result) -> { isLoading.set(false); currentInfo = result; currentNextPage = result.getNextPage(); @@ -157,7 +158,7 @@ public abstract class BaseListInfoFragment * * @return Rx {@link Single} containing the {@link ListExtractor.InfoItemsPage} */ - protected abstract Single loadMoreItemsLogic(); + protected abstract Single> loadMoreItemsLogic(); @Override protected void loadMoreItems() { @@ -194,7 +195,7 @@ public abstract class BaseListInfoFragment } @Override - public void handleNextItems(final ListExtractor.InfoItemsPage result) { + public void handleNextItems(final ListExtractor.InfoItemsPage result) { super.handleNextItems(result); currentNextPage = result.getNextPage(); @@ -218,7 +219,7 @@ public abstract class BaseListInfoFragment //////////////////////////////////////////////////////////////////////////*/ @Override - public void handleResult(@NonNull final I result) { + public void handleResult(@NonNull final L result) { super.handleResult(result); name = result.getName(); 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 918facc4e..869503b5b 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 @@ -5,6 +5,7 @@ import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor; import android.content.Context; +import android.graphics.Color; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; @@ -22,9 +23,11 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.core.content.ContextCompat; +import com.google.android.material.snackbar.Snackbar; import com.jakewharton.rxbinding4.view.RxView; import org.schabi.newpipe.R; +import org.schabi.newpipe.database.subscription.NotificationMode; import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.databinding.ChannelHeaderBinding; import org.schabi.newpipe.databinding.FragmentChannelBinding; @@ -39,6 +42,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.local.subscription.SubscriptionManager; +import org.schabi.newpipe.local.feed.notifications.NotificationHelper; import org.schabi.newpipe.player.MainPlayer.PlayerType; import org.schabi.newpipe.player.playqueue.ChannelPlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue; @@ -64,7 +68,7 @@ import io.reactivex.rxjava3.functions.Consumer; import io.reactivex.rxjava3.functions.Function; import io.reactivex.rxjava3.schedulers.Schedulers; -public class ChannelFragment extends BaseListInfoFragment +public class ChannelFragment extends BaseListInfoFragment implements View.OnClickListener { private static final int BUTTON_DEBOUNCE_INTERVAL = 100; @@ -84,6 +88,7 @@ public class ChannelFragment extends BaseListInfoFragment private PlaylistControlBinding playlistControlBinding; private MenuItem menuRssButton; + private MenuItem menuNotifyButton; public static ChannelFragment getInstance(final int serviceId, final String url, final String name) { @@ -179,6 +184,7 @@ public class ChannelFragment extends BaseListInfoFragment + "menu = [" + menu + "], inflater = [" + inflater + "]"); } menuRssButton = menu.findItem(R.id.menu_item_rss); + menuNotifyButton = menu.findItem(R.id.menu_item_notify); } } @@ -188,6 +194,11 @@ public class ChannelFragment extends BaseListInfoFragment case R.id.action_settings: NavigationHelper.openSettings(requireContext()); break; + case R.id.menu_item_notify: + final boolean value = !item.isChecked(); + item.setEnabled(false); + setNotify(value); + break; case R.id.menu_item_rss: if (currentInfo != null) { ShareUtils.openUrlInBrowser( @@ -232,15 +243,22 @@ public class ChannelFragment extends BaseListInfoFragment .subscribe(getSubscribeUpdateMonitor(info), onError)); disposables.add(observable - // Some updates are very rapid - // (for example when calling the updateSubscription(info)) - // so only update the UI for the latest emission - // ("sync" the subscribe button's state) - .debounce(100, TimeUnit.MILLISECONDS) + .map(List::isEmpty) + .distinctUntilChanged() .observeOn(AndroidSchedulers.mainThread()) - .subscribe((List subscriptionEntities) -> - updateSubscribeButton(!subscriptionEntities.isEmpty()), onError)); + .subscribe(isEmpty -> updateSubscribeButton(!isEmpty), onError)); + disposables.add(observable + .map(List::isEmpty) + .distinctUntilChanged() + .skip(1) // channel has just been opened + .filter(x -> NotificationHelper.areNewStreamsNotificationsEnabled(requireContext())) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(isEmpty -> { + if (!isEmpty) { + showNotifySnackbar(); + } + }, onError)); } private Function mapOnSubscribe(final SubscriptionEntity subscription, @@ -320,6 +338,7 @@ public class ChannelFragment extends BaseListInfoFragment info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount()); + updateNotifyButton(null); subscribeButtonMonitor = monitorSubscribeButton( headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info)); } else { @@ -327,6 +346,7 @@ public class ChannelFragment extends BaseListInfoFragment Log.d(TAG, "Found subscription to this channel!"); } final SubscriptionEntity subscription = subscriptionEntities.get(0); + updateNotifyButton(subscription); subscribeButtonMonitor = monitorSubscribeButton( headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription)); } @@ -369,12 +389,51 @@ public class ChannelFragment extends BaseListInfoFragment AnimationType.LIGHT_SCALE_AND_ALPHA); } + private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) { + if (menuNotifyButton == null) { + return; + } + if (subscription != null) { + menuNotifyButton.setEnabled( + NotificationHelper.areNewStreamsNotificationsEnabled(requireContext()) + ); + menuNotifyButton.setChecked( + subscription.getNotificationMode() == NotificationMode.ENABLED + ); + } + + menuNotifyButton.setVisible(subscription != null); + } + + private void setNotify(final boolean isEnabled) { + disposables.add( + subscriptionManager + .updateNotificationMode( + currentInfo.getServiceId(), + currentInfo.getUrl(), + isEnabled ? NotificationMode.ENABLED : NotificationMode.DISABLED) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe() + ); + } + + /** + * Show a snackbar with the option to enable notifications on new streams for this channel. + */ + private void showNotifySnackbar() { + Snackbar.make(itemsList, R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG) + .setAction(R.string.get_notified, v -> setNotify(true)) + .setActionTextColor(Color.YELLOW) + .show(); + } + /*////////////////////////////////////////////////////////////////////////// // Load and handle //////////////////////////////////////////////////////////////////////////*/ @Override - protected Single loadMoreItemsLogic() { + protected Single> loadMoreItemsLogic() { return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPage); } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java index 3d11e90c0..3b092cc28 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java @@ -15,6 +15,7 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.comments.CommentsInfo; +import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.util.ExtractorHelper; @@ -22,7 +23,7 @@ import org.schabi.newpipe.util.ExtractorHelper; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; -public class CommentsFragment extends BaseListInfoFragment { +public class CommentsFragment extends BaseListInfoFragment { private final CompositeDisposable disposables = new CompositeDisposable(); private TextView emptyStateDesc; @@ -67,7 +68,7 @@ public class CommentsFragment extends BaseListInfoFragment { //////////////////////////////////////////////////////////////////////////*/ @Override - protected Single loadMoreItemsLogic() { + protected Single> loadMoreItemsLogic() { return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage); } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java index c25f18e8b..0b01627d6 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java @@ -21,6 +21,7 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.kiosk.KioskInfo; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory; import org.schabi.newpipe.extractor.localization.ContentCountry; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.KioskTranslator; @@ -53,7 +54,7 @@ import io.reactivex.rxjava3.core.Single; *

*/ -public class KioskFragment extends BaseListInfoFragment { +public class KioskFragment extends BaseListInfoFragment { @State String kioskId = ""; String kioskTranslatedName; @@ -145,7 +146,7 @@ public class KioskFragment extends BaseListInfoFragment { } @Override - public Single loadMoreItemsLogic() { + public Single> loadMoreItemsLogic() { return ExtractorHelper.getMoreKioskItems(serviceId, url, currentNextPage); } 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 84dcb4fd9..5bf20c144 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 @@ -1,11 +1,10 @@ package org.schabi.newpipe.fragments.list.playlist; -import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; -import android.app.Activity; import android.content.Context; +import android.content.res.ColorStateList; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; @@ -19,6 +18,10 @@ import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; +import androidx.core.content.ContextCompat; + +import com.google.android.material.shape.CornerFamily; +import com.google.android.material.shape.ShapeAppearanceModel; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; @@ -36,24 +39,20 @@ import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper; import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; -import org.schabi.newpipe.info_list.InfoItemDialog; +import org.schabi.newpipe.info_list.dialog.InfoItemDialog; import org.schabi.newpipe.local.playlist.RemotePlaylistManager; import org.schabi.newpipe.player.MainPlayer.PlayerType; -import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PicassoHelper; -import org.schabi.newpipe.util.StreamDialogEntry; -import org.schabi.newpipe.util.external_communication.KoreUtils; +import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Supplier; @@ -64,7 +63,7 @@ import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; -public class PlaylistFragment extends BaseListInfoFragment { +public class PlaylistFragment extends BaseListInfoFragment { private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG"; @@ -140,60 +139,22 @@ public class PlaylistFragment extends BaseListInfoFragment { } @Override - protected void showStreamDialog(final StreamInfoItem item) { + protected void showInfoItemDialog(final StreamInfoItem item) { final Context context = getContext(); - final Activity activity = getActivity(); - if (context == null || context.getResources() == null || activity == null) { - return; + try { + final InfoItemDialog.Builder dialogBuilder = + new InfoItemDialog.Builder(getActivity(), context, this, item); + + dialogBuilder + .setAction( + StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND, + (f, infoItem) -> NavigationHelper.playOnBackgroundPlayer( + context, getPlayQueueStartingAt(infoItem), true)) + .create() + .show(); + } catch (final IllegalArgumentException e) { + InfoItemDialog.Builder.reportErrorDuringInitialization(e, item); } - - final ArrayList entries = new ArrayList<>(); - - if (PlayerHolder.getInstance().isPlayQueueReady()) { - entries.add(StreamDialogEntry.enqueue); - - if (PlayerHolder.getInstance().getQueueSize() > 1) { - entries.add(StreamDialogEntry.enqueue_next); - } - } - - if (item.getStreamType() == StreamType.AUDIO_STREAM) { - entries.addAll(Arrays.asList( - StreamDialogEntry.start_here_on_background, - StreamDialogEntry.append_playlist, - StreamDialogEntry.share - )); - } else { - entries.addAll(Arrays.asList( - StreamDialogEntry.start_here_on_background, - StreamDialogEntry.start_here_on_popup, - StreamDialogEntry.append_playlist, - StreamDialogEntry.share - )); - } - entries.add(StreamDialogEntry.open_in_browser); - if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) { - entries.add(StreamDialogEntry.play_with_kodi); - } - - // show "mark as watched" only when watch history is enabled - if (StreamDialogEntry.shouldAddMarkAsWatched(item.getStreamType(), context)) { - entries.add( - StreamDialogEntry.mark_as_watched - ); - } - if (!isNullOrEmpty(item.getUploaderUrl())) { - entries.add(StreamDialogEntry.show_channel_details); - } - - StreamDialogEntry.setEnabledEntries(entries); - - StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItem) -> - NavigationHelper.playOnBackgroundPlayer(context, - getPlayQueueStartingAt(infoItem), true)); - - new InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context), - (dialog, which) -> StreamDialogEntry.clickOn(which, this, item)).show(); } @Override @@ -249,7 +210,7 @@ public class PlaylistFragment extends BaseListInfoFragment { //////////////////////////////////////////////////////////////////////////*/ @Override - protected Single loadMoreItemsLogic() { + protected Single> loadMoreItemsLogic() { return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPage); } @@ -328,9 +289,14 @@ public class PlaylistFragment extends BaseListInfoFragment { && (YoutubeParsingHelper.isYoutubeMixId(result.getId()) || YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) { // this is an auto-generated playlist (e.g. Youtube mix), so a radio is shown - headerBinding.uploaderAvatarView.setDisableCircularTransformation(true); - headerBinding.uploaderAvatarView.setBorderColor( - getResources().getColor(R.color.transparent_background_color)); + final ShapeAppearanceModel model = ShapeAppearanceModel.builder() + .setAllCorners(CornerFamily.ROUNDED, 0f) + .build(); // this turns the image back into a square + headerBinding.uploaderAvatarView.setShapeAppearanceModel(model); + headerBinding.uploaderAvatarView.setStrokeColor( + ColorStateList.valueOf(ContextCompat.getColor( + requireContext(), R.color.transparent_background_color)) + ); headerBinding.uploaderAvatarView.setImageDrawable( AppCompatResources.getDrawable(requireContext(), R.drawable.ic_radio) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java index 3cfcfd470..fb983b01e 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java @@ -7,6 +7,7 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.R; @@ -34,8 +35,10 @@ public class SuggestionListAdapter this.listener = listener; } + @NonNull @Override - public SuggestionItemHolder onCreateViewHolder(final ViewGroup parent, final int viewType) { + public SuggestionItemHolder onCreateViewHolder(@NonNull final ViewGroup parent, + final int viewType) { return new SuggestionItemHolder(LayoutInflater.from(context) .inflate(R.layout.item_search_suggestion, parent, false)); } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java index 7ba6aa2ab..f0ece69f3 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java @@ -15,6 +15,7 @@ import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.RelatedItemsHeaderBinding; import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; @@ -26,7 +27,7 @@ import java.util.function.Supplier; import io.reactivex.rxjava3.core.Single; -public class RelatedItemsFragment extends BaseListInfoFragment +public class RelatedItemsFragment extends BaseListInfoFragment implements SharedPreferences.OnSharedPreferenceChangeListener { private static final String INFO_KEY = "related_info_key"; @@ -86,7 +87,7 @@ public class RelatedItemsFragment extends BaseListInfoFragment } @Override - protected Single loadMoreItemsLogic() { + protected Single> loadMoreItemsLogic() { return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage); } 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 deleted file mode 100644 index c485337f0..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemDialog.java +++ /dev/null @@ -1,54 +0,0 @@ -package org.schabi.newpipe.info_list; - -import android.app.Activity; -import android.content.DialogInterface; -import android.view.View; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; - -public class InfoItemDialog { - private final AlertDialog dialog; - - public InfoItemDialog(@NonNull final Activity activity, - @NonNull final StreamInfoItem info, - @NonNull final String[] commands, - @NonNull final DialogInterface.OnClickListener actions) { - this(activity, commands, actions, info.getName(), info.getUploaderName()); - } - - public InfoItemDialog(@NonNull final Activity activity, - @NonNull final String[] commands, - @NonNull final DialogInterface.OnClickListener actions, - @NonNull final String title, - @Nullable final String additionalDetail) { - - final View bannerView = View.inflate(activity, R.layout.dialog_title, null); - bannerView.setSelected(true); - - final TextView titleView = bannerView.findViewById(R.id.itemTitleView); - titleView.setText(title); - - final TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails); - if (additionalDetail != null) { - detailsView.setText(additionalDetail); - detailsView.setVisibility(View.VISIBLE); - } else { - detailsView.setVisibility(View.GONE); - } - - dialog = new AlertDialog.Builder(activity) - .setCustomTitle(bannerView) - .setItems(commands, actions) - .create(); - } - - public void show() { - dialog.show(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java new file mode 100644 index 000000000..5a266c0a8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java @@ -0,0 +1,356 @@ +package org.schabi.newpipe.info_list.dialog; + +import static org.schabi.newpipe.MainActivity.DEBUG; + +import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Build; +import android.util.Log; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.preference.PreferenceManager; + +import org.schabi.newpipe.App; +import org.schabi.newpipe.R; +import org.schabi.newpipe.error.ErrorInfo; +import org.schabi.newpipe.error.ErrorUtil; +import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipe.player.helper.PlayerHolder; +import org.schabi.newpipe.util.external_communication.KoreUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +/** + * Dialog for a {@link StreamInfoItem}. + * The dialog's content are actions that can be performed on the {@link StreamInfoItem}. + * This dialog is mostly used for longpress context menus. + */ +public final class InfoItemDialog { + private static final String TAG = Build.class.getSimpleName(); + /** + * Ideally, {@link InfoItemDialog} would extend {@link AlertDialog}. + * However, extending {@link AlertDialog} requires many additional lines + * and brings more complexity to this class, especially the constructor. + * To circumvent this, an {@link AlertDialog.Builder} is used in the constructor. + * Its result is stored in this class variable to allow access via the {@link #show()} method. + */ + private final AlertDialog dialog; + + private InfoItemDialog(@NonNull final Activity activity, + @NonNull final Fragment fragment, + @NonNull final StreamInfoItem info, + @NonNull final List entries) { + + // Create the dialog's title + final View bannerView = View.inflate(activity, R.layout.dialog_title, null); + bannerView.setSelected(true); + + final TextView titleView = bannerView.findViewById(R.id.itemTitleView); + titleView.setText(info.getName()); + + final TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails); + if (info.getUploaderName() != null) { + detailsView.setText(info.getUploaderName()); + detailsView.setVisibility(View.VISIBLE); + } else { + detailsView.setVisibility(View.GONE); + } + + // Get the entry's descriptions which are displayed in the dialog + final String[] items = entries.stream() + .map(entry -> entry.getString(activity)).toArray(String[]::new); + + // Call an entry's action / onClick method when the entry is selected. + final DialogInterface.OnClickListener action = (d, index) -> + entries.get(index).action.onClick(fragment, info); + + dialog = new AlertDialog.Builder(activity) + .setCustomTitle(bannerView) + .setItems(items, action) + .create(); + + } + + public void show() { + dialog.show(); + } + + /** + *

Builder to generate a {@link InfoItemDialog} for a {@link StreamInfoItem}.

+ * Use {@link #addEntry(StreamDialogDefaultEntry)} + * and {@link #addAllEntries(StreamDialogDefaultEntry...)} to add options to the dialog. + *
+ * Custom actions for entries can be set using + * {@link #setAction(StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)}. + */ + public static class Builder { + @NonNull private final Activity activity; + @NonNull private final Context context; + @NonNull private final StreamInfoItem infoItem; + @NonNull private final Fragment fragment; + @NonNull private final List entries = new ArrayList<>(); + private final boolean addDefaultEntriesAutomatically; + + /** + *

Create a {@link Builder builder} instance for a {@link StreamInfoItem} + * that automatically adds the some default entries + * at the top and bottom of the dialog.

+ * The dialog has the following structure: + *
+         *     + - - - - - - - - - - - - - - - - - - - - - -+
+         *     | ENQUEUE                                    |
+         *     | ENQUEUE_NEXT                               |
+         *     | START_ON_BACKGROUND                        |
+         *     | START_ON_POPUP                             |
+         *     + - - - - - - - - - - - - - - - - - - - - - -+
+         *     | entries added manually with                |
+         *     | addEntry() and addAllEntries()             |
+         *     + - - - - - - - - - - - - - - - - - - - - - -+
+         *     | APPEND_PLAYLIST                            |
+         *     | SHARE                                      |
+         *     | OPEN_IN_BROWSER                            |
+         *     | PLAY_WITH_KODI                             |
+         *     | MARK_AS_WATCHED                            |
+         *     | SHOW_CHANNEL_DETAILS                       |
+         *     + - - - - - - - - - - - - - - - - - - - - - -+
+         * 
+ * Please note that some entries are not added depending on the user's preferences, + * the item's {@link StreamType} and the current player state. + * + * @param activity + * @param context + * @param fragment + * @param infoItem the item for this dialog; all entries and their actions work with + * this {@link StreamInfoItem} + * @throws IllegalArgumentException if activity, context + * or resources is null + */ + public Builder(final Activity activity, + final Context context, + @NonNull final Fragment fragment, + @NonNull final StreamInfoItem infoItem) { + this(activity, context, fragment, infoItem, true); + } + + /** + *

Create an instance of this {@link Builder} for a {@link StreamInfoItem}.

+ *

If {@code addDefaultEntriesAutomatically} is set to {@code true}, + * some default entries are added to the top and bottom of the dialog.

+ * The dialog has the following structure: + *
+         *     + - - - - - - - - - - - - - - - - - - - - - -+
+         *     | ENQUEUE                                    |
+         *     | ENQUEUE_NEXT                               |
+         *     | START_ON_BACKGROUND                        |
+         *     | START_ON_POPUP                             |
+         *     + - - - - - - - - - - - - - - - - - - - - - -+
+         *     | entries added manually with                |
+         *     | addEntry() and addAllEntries()             |
+         *     + - - - - - - - - - - - - - - - - - - - - - -+
+         *     | APPEND_PLAYLIST                            |
+         *     | SHARE                                      |
+         *     | OPEN_IN_BROWSER                            |
+         *     | PLAY_WITH_KODI                             |
+         *     | MARK_AS_WATCHED                            |
+         *     | SHOW_CHANNEL_DETAILS                       |
+         *     + - - - - - - - - - - - - - - - - - - - - - -+
+         * 
+ * Please note that some entries are not added depending on the user's preferences, + * the item's {@link StreamType} and the current player state. + * + * @param activity + * @param context + * @param fragment + * @param infoItem + * @param addDefaultEntriesAutomatically + * whether default entries added with {@link #addDefaultBeginningEntries()} + * and {@link #addDefaultEndEntries()} are added automatically when generating + * the {@link InfoItemDialog}. + *
+ * Entries added with {@link #addEntry(StreamDialogDefaultEntry)} and + * {@link #addAllEntries(StreamDialogDefaultEntry...)} are added in between. + * @throws IllegalArgumentException if activity, context + * or resources is null + */ + public Builder(final Activity activity, + final Context context, + @NonNull final Fragment fragment, + @NonNull final StreamInfoItem infoItem, + final boolean addDefaultEntriesAutomatically) { + if (activity == null || context == null || context.getResources() == null) { + if (DEBUG) { + Log.d(TAG, "activity, context or resources is null: activity = " + + activity + ", context = " + context); + } + throw new IllegalArgumentException("activity, context or resources is null"); + } + this.activity = activity; + this.context = context; + this.fragment = fragment; + this.infoItem = infoItem; + this.addDefaultEntriesAutomatically = addDefaultEntriesAutomatically; + if (addDefaultEntriesAutomatically) { + addDefaultBeginningEntries(); + } + } + + /** + * Adds a new entry and appends it to the current entry list. + * @param entry the entry to add + * @return the current {@link Builder} instance + */ + public Builder addEntry(@NonNull final StreamDialogDefaultEntry entry) { + entries.add(entry.toStreamDialogEntry()); + return this; + } + + /** + * Adds new entries. These are appended to the current entry list. + * @param newEntries the entries to add + * @return the current {@link Builder} instance + */ + public Builder addAllEntries(@NonNull final StreamDialogDefaultEntry... newEntries) { + Stream.of(newEntries).forEach(this::addEntry); + return this; + } + + /** + *

Change an entries' action that is called when the entry is selected.

+ *

Warning: Only use this method when the entry has been already added. + * Changing the action of an entry which has not been added to the Builder yet + * does not have an effect.

+ * @param entry the entry to change + * @param action the action to perform when the entry is selected + * @return the current {@link Builder} instance + */ + public Builder setAction(@NonNull final StreamDialogDefaultEntry entry, + @NonNull final StreamDialogEntry.StreamDialogEntryAction action) { + for (int i = 0; i < entries.size(); i++) { + if (entries.get(i).resource == entry.resource) { + entries.set(i, new StreamDialogEntry(entry.resource, action)); + return this; + } + } + return this; + } + + /** + * Adds {@link StreamDialogDefaultEntry#ENQUEUE} if the player is open and + * {@link StreamDialogDefaultEntry#ENQUEUE_NEXT} if there are multiple streams + * in the play queue. + * @return the current {@link Builder} instance + */ + public Builder addEnqueueEntriesIfNeeded() { + if (PlayerHolder.getInstance().isPlayQueueReady()) { + addEntry(StreamDialogDefaultEntry.ENQUEUE); + + if (PlayerHolder.getInstance().getQueueSize() > 1) { + addEntry(StreamDialogDefaultEntry.ENQUEUE_NEXT); + } + } + return this; + } + + /** + * Adds the {@link StreamDialogDefaultEntry#START_HERE_ON_BACKGROUND}. + * If the {@link #infoItem} is not a pure audio (live) stream, + * {@link StreamDialogDefaultEntry#START_HERE_ON_POPUP} is added, too. + * @return the current {@link Builder} instance + */ + public Builder addStartHereEntries() { + addEntry(StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND); + if (infoItem.getStreamType() != StreamType.AUDIO_STREAM + && infoItem.getStreamType() != StreamType.AUDIO_LIVE_STREAM) { + addEntry(StreamDialogDefaultEntry.START_HERE_ON_POPUP); + } + return this; + } + + /** + * Adds {@link StreamDialogDefaultEntry#MARK_AS_WATCHED} if the watch history is enabled + * and the stream is not a livestream. + * @return the current {@link Builder} instance + */ + public Builder addMarkAsWatchedEntryIfNeeded() { + final boolean isWatchHistoryEnabled = PreferenceManager + .getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.enable_watch_history_key), false); + if (isWatchHistoryEnabled + && infoItem.getStreamType() != StreamType.LIVE_STREAM + && infoItem.getStreamType() != StreamType.AUDIO_LIVE_STREAM) { + addEntry(StreamDialogDefaultEntry.MARK_AS_WATCHED); + } + return this; + } + + /** + * Adds the {@link StreamDialogDefaultEntry#PLAY_WITH_KODI} entry if it is needed. + * @return the current {@link Builder} instance + */ + public Builder addPlayWithKodiEntryIfNeeded() { + if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { + addEntry(StreamDialogDefaultEntry.PLAY_WITH_KODI); + } + return this; + } + + /** + * Add the entries which are usually at the top of the action list. + *
+ * This method adds the "enqueue" (see {@link #addEnqueueEntriesIfNeeded()}) + * and "start here" (see {@link #addStartHereEntries()} entries. + * @return the current {@link Builder} instance + */ + public Builder addDefaultBeginningEntries() { + addEnqueueEntriesIfNeeded(); + addStartHereEntries(); + return this; + } + + /** + * Add the entries which are usually at the bottom of the action list. + * @return the current {@link Builder} instance + */ + public Builder addDefaultEndEntries() { + addAllEntries( + StreamDialogDefaultEntry.APPEND_PLAYLIST, + StreamDialogDefaultEntry.SHARE, + StreamDialogDefaultEntry.OPEN_IN_BROWSER + ); + addPlayWithKodiEntryIfNeeded(); + addMarkAsWatchedEntryIfNeeded(); + addEntry(StreamDialogDefaultEntry.SHOW_CHANNEL_DETAILS); + return this; + } + + /** + * Creates the {@link InfoItemDialog}. + * @return a new instance of {@link InfoItemDialog} + */ + public InfoItemDialog create() { + if (addDefaultEntriesAutomatically) { + addDefaultEndEntries(); + } + return new InfoItemDialog(this.activity, this.fragment, this.infoItem, this.entries); + } + + public static void reportErrorDuringInitialization(final Throwable throwable, + final InfoItem item) { + ErrorUtil.showSnackbar(App.getApp().getBaseContext(), new ErrorInfo( + throwable, + UserAction.OPEN_INFO_ITEM_DIALOG, + "none", + item.getServiceId())); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java new file mode 100644 index 000000000..7e87318ee --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java @@ -0,0 +1,142 @@ +package org.schabi.newpipe.info_list.dialog; + +import static org.schabi.newpipe.util.NavigationHelper.openChannelFragment; +import static org.schabi.newpipe.util.SparseItemUtil.fetchItemInfoIfSparse; +import static org.schabi.newpipe.util.SparseItemUtil.fetchUploaderUrlIfSparse; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.stream.model.StreamEntity; +import org.schabi.newpipe.local.dialog.PlaylistAppendDialog; +import org.schabi.newpipe.local.dialog.PlaylistDialog; +import org.schabi.newpipe.local.history.HistoryRecordManager; +import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.external_communication.KoreUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; + +import java.util.Collections; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; + +/** + *

+ * This enum provides entries that are accepted + * by the {@link InfoItemDialog.Builder}. + *

+ *

+ * These entries contain a String {@link #resource} which is displayed in the dialog and + * a default {@link #action} that is executed + * when the entry is selected (via onClick()). + *
+ * They action can be overridden by using the Builder's + * {@link InfoItemDialog.Builder#setAction( + * StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)} + * method. + *

+ */ +public enum StreamDialogDefaultEntry { + SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) -> + fetchUploaderUrlIfSparse(fragment.requireContext(), item.getServiceId(), item.getUrl(), + item.getUploaderUrl(), url -> openChannelFragment(fragment, item, url)) + ), + + /** + * Enqueues the stream automatically to the current PlayerType. + */ + ENQUEUE(R.string.enqueue_stream, (fragment, item) -> + fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue -> + NavigationHelper.enqueueOnPlayer(fragment.getContext(), singlePlayQueue)) + ), + + /** + * Enqueues the stream automatically to the current PlayerType + * after the currently playing stream. + */ + ENQUEUE_NEXT(R.string.enqueue_next_stream, (fragment, item) -> + fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue -> + NavigationHelper.enqueueNextOnPlayer(fragment.getContext(), singlePlayQueue)) + ), + + START_HERE_ON_BACKGROUND(R.string.start_here_on_background, (fragment, item) -> + fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue -> + NavigationHelper.playOnBackgroundPlayer( + fragment.getContext(), singlePlayQueue, true))), + + START_HERE_ON_POPUP(R.string.start_here_on_popup, (fragment, item) -> + fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue -> + NavigationHelper.playOnPopupPlayer(fragment.getContext(), singlePlayQueue, true))), + + SET_AS_PLAYLIST_THUMBNAIL(R.string.set_as_playlist_thumbnail, (fragment, item) -> { + throw new UnsupportedOperationException("This needs to be implemented manually " + + "by using InfoItemDialog.Builder.setAction()"); + }), + + DELETE(R.string.delete, (fragment, item) -> { + throw new UnsupportedOperationException("This needs to be implemented manually " + + "by using InfoItemDialog.Builder.setAction()"); + }), + + /** + * Opens a {@link PlaylistDialog} to either append the stream to a playlist + * or create a new playlist if there are no local playlists. + */ + APPEND_PLAYLIST(R.string.add_to_playlist, (fragment, item) -> + PlaylistDialog.createCorrespondingDialog( + fragment.getContext(), + Collections.singletonList(new StreamEntity(item)), + dialog -> dialog.show( + fragment.getParentFragmentManager(), + "StreamDialogEntry@" + + (dialog instanceof PlaylistAppendDialog ? "append" : "create") + + "_playlist" + ) + ) + ), + + PLAY_WITH_KODI(R.string.play_with_kodi_title, (fragment, item) -> { + final Uri videoUrl = Uri.parse(item.getUrl()); + try { + NavigationHelper.playWithKore(fragment.requireContext(), videoUrl); + } catch (final Exception e) { + KoreUtils.showInstallKoreDialog(fragment.requireActivity()); + } + }), + + SHARE(R.string.share, (fragment, item) -> + ShareUtils.shareText(fragment.requireContext(), item.getName(), item.getUrl(), + item.getThumbnailUrl())), + + OPEN_IN_BROWSER(R.string.open_in_browser, (fragment, item) -> + ShareUtils.openUrlInBrowser(fragment.requireContext(), item.getUrl())), + + + MARK_AS_WATCHED(R.string.mark_as_watched, (fragment, item) -> + new HistoryRecordManager(fragment.getContext()) + .markAsWatched(item) + .onErrorComplete() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe() + ); + + + @StringRes + public final int resource; + @NonNull + public final StreamDialogEntry.StreamDialogEntryAction action; + + StreamDialogDefaultEntry(@StringRes final int resource, + @NonNull final StreamDialogEntry.StreamDialogEntryAction action) { + this.resource = resource; + this.action = action; + } + + @NonNull + public StreamDialogEntry toStreamDialogEntry() { + return new StreamDialogEntry(resource, action); + } + +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java new file mode 100644 index 000000000..9d82e3b58 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java @@ -0,0 +1,31 @@ +package org.schabi.newpipe.info_list.dialog; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.fragment.app.Fragment; + +import org.schabi.newpipe.extractor.stream.StreamInfoItem; + +public class StreamDialogEntry { + + @StringRes + public final int resource; + @NonNull + public final StreamDialogEntryAction action; + + public StreamDialogEntry(@StringRes final int resource, + @NonNull final StreamDialogEntryAction action) { + this.resource = resource; + this.action = action; + } + + public String getString(@NonNull final Context context) { + return context.getString(resource); + } + + public interface StreamDialogEntryAction { + void onClick(Fragment fragment, StreamInfoItem infoItem); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java index 78acb752b..aa4f4c9f0 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.info_list.holder; import android.view.ViewGroup; +import android.widget.ImageView; import android.widget.TextView; import org.schabi.newpipe.R; @@ -11,10 +12,8 @@ import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.Localization; -import de.hdodenhof.circleimageview.CircleImageView; - public class ChannelMiniInfoItemHolder extends InfoItemHolder { - public final CircleImageView itemThumbnailView; + public final ImageView itemThumbnailView; public final TextView itemTitleView; private final TextView itemAdditionalDetailView; diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java index cb47efa92..6e4773c09 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java @@ -7,6 +7,7 @@ import android.text.util.Linkify; import android.util.Log; import android.view.View; import android.view.ViewGroup; +import android.widget.ImageView; import android.widget.RelativeLayout; import android.widget.TextView; @@ -28,8 +29,6 @@ import org.schabi.newpipe.util.PicassoHelper; import java.util.regex.Matcher; -import de.hdodenhof.circleimageview.CircleImageView; - public class CommentsMiniInfoItemHolder extends InfoItemHolder { private static final String TAG = "CommentsMiniIIHolder"; @@ -40,7 +39,7 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { private final int commentVerticalPadding; private final RelativeLayout itemRoot; - public final CircleImageView itemThumbnailView; + public final ImageView itemThumbnailView; private final TextView itemContentView; private final TextView itemLikesCountView; private final TextView itemPublishedTime; diff --git a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java index 5d81c0069..05e2fdac0 100644 --- a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java @@ -228,6 +228,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter streamEntities) { - super(streamEntities); + /** + * Create a new instance of {@link PlaylistAppendDialog}. + * + * @param streamEntities a list of {@link StreamEntity} to be added to playlists + * @return a new instance of {@link PlaylistAppendDialog} + */ + public static PlaylistAppendDialog newInstance(final List streamEntities) { + final PlaylistAppendDialog dialog = new PlaylistAppendDialog(); + dialog.setStreamEntities(streamEntities); + return dialog; } /*////////////////////////////////////////////////////////////////////////// @@ -103,13 +111,14 @@ public final class PlaylistAppendDialog extends PlaylistDialog { // Helper //////////////////////////////////////////////////////////////////////////*/ + /** Display create playlist dialog. */ public void openCreatePlaylistDialog() { if (getStreamEntities() == null || !isAdded()) { return; } final PlaylistCreationDialog playlistCreationDialog = - new PlaylistCreationDialog(getStreamEntities()); + PlaylistCreationDialog.newInstance(getStreamEntities()); // Move the dismissListener to the new dialog. playlistCreationDialog.setOnDismissListener(this.getOnDismissListener()); this.setOnDismissListener(null); diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java index 6664144cd..0c09f3f0d 100644 --- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java +++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java @@ -21,8 +21,17 @@ import java.util.List; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; public final class PlaylistCreationDialog extends PlaylistDialog { - public PlaylistCreationDialog(final List streamEntities) { - super(streamEntities); + + /** + * Create a new instance of {@link PlaylistCreationDialog}. + * + * @param streamEntities a list of {@link StreamEntity} to be added to playlists + * @return a new instance of {@link PlaylistCreationDialog} + */ + public static PlaylistCreationDialog newInstance(final List streamEntities) { + final PlaylistCreationDialog dialog = new PlaylistCreationDialog(); + dialog.setStreamEntities(streamEntities); + return dialog; } /*////////////////////////////////////////////////////////////////////////// diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java index c2d4474f8..f568ef81a 100644 --- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java +++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java @@ -31,10 +31,6 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave private org.schabi.newpipe.util.SavedState savedState; - public PlaylistDialog(final List streamEntities) { - this.streamEntities = streamEntities; - } - /*////////////////////////////////////////////////////////////////////////// // LifeCycle //////////////////////////////////////////////////////////////////////////*/ @@ -97,7 +93,7 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave } @Override - public void onSaveInstanceState(final Bundle outState) { + public void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); if (getActivity() != null) { savedState = StateSaver.tryToSave(getActivity().isChangingConfigurations(), @@ -120,6 +116,10 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave this.onDismissListener = onDismissListener; } + protected void setStreamEntities(final List streamEntities) { + this.streamEntities = streamEntities; + } + /*////////////////////////////////////////////////////////////////////////// // Dialog creation //////////////////////////////////////////////////////////////////////////*/ @@ -143,8 +143,8 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave .observeOn(AndroidSchedulers.mainThread()) .subscribe(hasPlaylists -> onExec.accept(hasPlaylists - ? new PlaylistAppendDialog(streamEntities) - : new PlaylistCreationDialog(streamEntities)) + ? PlaylistAppendDialog.newInstance(streamEntities) + : PlaylistCreationDialog.newInstance(streamEntities)) ); } } diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt index e28f2d31a..7a8723ceb 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt @@ -14,6 +14,7 @@ import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity import org.schabi.newpipe.database.stream.StreamWithState import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.local.subscription.FeedGroupIcon @@ -57,6 +58,11 @@ class FeedDatabaseManager(context: Context) { fun outdatedSubscriptions(outdatedThreshold: OffsetDateTime) = feedTable.getAllOutdated(outdatedThreshold) + fun outdatedSubscriptionsWithNotificationMode( + outdatedThreshold: OffsetDateTime, + @NotificationMode notificationMode: Int + ) = feedTable.getOutdatedWithNotificationMode(outdatedThreshold, notificationMode) + fun notLoadedCount(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable { return when (groupId) { FeedGroupEntity.GROUP_ALL_ID -> feedTable.notLoadedCount() @@ -72,6 +78,10 @@ class FeedDatabaseManager(context: Context) { fun markAsOutdated(subscriptionId: Long) = feedTable .setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null)) + fun doesStreamExist(stream: StreamInfoItem): Boolean { + return streamTable.exists(stream.serviceId, stream.url) + } + fun upsertAll( subscriptionId: Long, items: List, diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt index e6da0d545..e97629f31 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt @@ -50,7 +50,6 @@ import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import com.xwray.groupie.GroupieAdapter import com.xwray.groupie.Item -import com.xwray.groupie.OnAsyncUpdateListener import com.xwray.groupie.OnItemClickListener import com.xwray.groupie.OnItemLongClickListener import icepick.State @@ -68,25 +67,21 @@ import org.schabi.newpipe.error.UserAction import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.stream.StreamInfoItem -import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty import org.schabi.newpipe.fragments.BaseStateFragment -import org.schabi.newpipe.info_list.InfoItemDialog +import org.schabi.newpipe.info_list.dialog.InfoItemDialog import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling import org.schabi.newpipe.ktx.slideUp import org.schabi.newpipe.local.feed.item.StreamItem import org.schabi.newpipe.local.feed.service.FeedLoadService import org.schabi.newpipe.local.subscription.SubscriptionManager -import org.schabi.newpipe.player.helper.PlayerHolder import org.schabi.newpipe.util.DeviceUtils import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.NavigationHelper -import org.schabi.newpipe.util.StreamDialogEntry import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout import java.time.OffsetDateTime -import java.util.ArrayList import java.util.function.Consumer class FeedFragment : BaseStateFragment() { @@ -143,7 +138,7 @@ class FeedFragment : BaseStateFragment() { val factory = FeedViewModel.Factory(requireContext(), groupId) viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java) showPlayedItems = viewModel.getShowPlayedItemsFromPreferences() - viewModel.stateLiveData.observe(viewLifecycleOwner, { it?.let(::handleResult) }) + viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) } groupAdapter = GroupieAdapter().apply { setOnItemClickListener(listenerStreamItem) @@ -356,53 +351,12 @@ class FeedFragment : BaseStateFragment() { feedBinding.loadingProgressBar.max = progressState.maxProgress } - private fun showStreamDialog(item: StreamInfoItem) { + private fun showInfoItemDialog(item: StreamInfoItem) { val context = context val activity: Activity? = getActivity() if (context == null || context.resources == null || activity == null) return - val entries = ArrayList() - if (PlayerHolder.getInstance().isPlayQueueReady) { - entries.add(StreamDialogEntry.enqueue) - - if (PlayerHolder.getInstance().queueSize > 1) { - entries.add(StreamDialogEntry.enqueue_next) - } - } - - if (item.streamType == StreamType.AUDIO_STREAM) { - entries.addAll( - listOf( - StreamDialogEntry.start_here_on_background, - StreamDialogEntry.append_playlist, - StreamDialogEntry.share, - StreamDialogEntry.open_in_browser - ) - ) - } else { - entries.addAll( - listOf( - StreamDialogEntry.start_here_on_background, - StreamDialogEntry.start_here_on_popup, - StreamDialogEntry.append_playlist, - StreamDialogEntry.share, - StreamDialogEntry.open_in_browser - ) - ) - } - - // show "mark as watched" only when watch history is enabled - if (StreamDialogEntry.shouldAddMarkAsWatched(item.streamType, context)) { - entries.add( - StreamDialogEntry.mark_as_watched - ) - } - entries.add(StreamDialogEntry.show_channel_details) - - StreamDialogEntry.setEnabledEntries(entries) - InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context)) { _, which -> - StreamDialogEntry.clickOn(which, this, item) - }.show() + InfoItemDialog.Builder(activity, context, this, item).create().show() } private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener { @@ -418,7 +372,7 @@ class FeedFragment : BaseStateFragment() { override fun onItemLongClick(item: Item<*>, view: View): Boolean { if (item is StreamItem && !isRefreshing) { - showStreamDialog(item.streamWithState.stream.toStreamInfoItem()) + showInfoItemDialog(item.streamWithState.stream.toStreamInfoItem()) return true } return false @@ -438,14 +392,11 @@ class FeedFragment : BaseStateFragment() { // This need to be saved in a variable as the update occurs async val oldOldestSubscriptionUpdate = oldestSubscriptionUpdate - groupAdapter.updateAsync( - loadedState.items, false, - OnAsyncUpdateListener { - oldOldestSubscriptionUpdate?.run { - highlightNewItemsAfter(oldOldestSubscriptionUpdate) - } + groupAdapter.updateAsync(loadedState.items, false) { + oldOldestSubscriptionUpdate?.run { + highlightNewItemsAfter(oldOldestSubscriptionUpdate) } - ) + } listState?.run { feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState) @@ -497,8 +448,7 @@ class FeedFragment : BaseStateFragment() { }.subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( - { - subscriptionEntity -> + { subscriptionEntity -> handleFeedNotAvailable( subscriptionEntity, t.cause, diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt index 2cbf9ad05..e21963c16 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt @@ -56,7 +56,7 @@ class FeedViewModel( .subscribeOn(Schedulers.io()) .observeOn(Schedulers.io()) .map { (event, showPlayedItems, notLoadedCount, oldestUpdate) -> - var streamItems = if (event is SuccessResultEvent || event is IdleEvent) + val streamItems = if (event is SuccessResultEvent || event is IdleEvent) feedDatabaseManager .getStreams(groupId, showPlayedItems) .blockingGet(arrayListOf()) diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt new file mode 100644 index 000000000..3a08b3e4a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt @@ -0,0 +1,145 @@ +package org.schabi.newpipe.local.feed.notifications + +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import androidx.preference.PreferenceManager +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.local.feed.service.FeedUpdateInfo +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.PicassoHelper + +/** + * Helper for everything related to show notifications about new streams to the user. + */ +class NotificationHelper(val context: Context) { + + private val manager = context.getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager + + /** + * Show a notification about new streams from a single channel. + * Opening the notification will open the corresponding channel page. + */ + fun displayNewStreamsNotification(data: FeedUpdateInfo) { + val newStreams: List = data.newStreams + val summary = context.resources.getQuantityString( + R.plurals.new_streams, newStreams.size, newStreams.size + ) + val builder = NotificationCompat.Builder( + context, + context.getString(R.string.streams_notification_channel_id) + ) + .setContentTitle(Localization.concatenateStrings(data.name, summary)) + .setContentText( + data.listInfo.relatedItems.joinToString( + context.getString(R.string.enumeration_comma) + ) { x -> x.name } + ) + .setNumber(newStreams.size) + .setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setColor(ContextCompat.getColor(context, R.color.ic_launcher_background)) + .setColorized(true) + .setAutoCancel(true) + .setCategory(NotificationCompat.CATEGORY_SOCIAL) + + // Build style + val style = NotificationCompat.InboxStyle() + newStreams.forEach { style.addLine(it.name) } + style.setSummaryText(summary) + style.setBigContentTitle(data.name) + builder.setStyle(style) + + // open the channel page when clicking on the notification + builder.setContentIntent( + PendingIntent.getActivity( + context, + data.pseudoId, + NavigationHelper + .getChannelIntent(context, data.listInfo.serviceId, data.listInfo.url) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + PendingIntent.FLAG_IMMUTABLE + else + 0 + ) + ) + + PicassoHelper.loadNotificationIcon(data.avatarUrl) { bitmap -> + bitmap?.let { builder.setLargeIcon(it) } // set only if != null + manager.notify(data.pseudoId, builder.build()) + } + } + + companion object { + /** + * Check whether notifications are enabled on the device. + * Users can disable them via the system settings for a single app. + * If this is the case, the app cannot create any notifications + * and display them to the user. + *
+ * On Android 26 and above, notification channels are used by NewPipe. + * These can be configured by the user, too. + * The notification channel for new streams is also checked by this method. + * + * @param context Context + * @return true if notifications are allowed and can be displayed; + * false otherwise + */ + fun areNotificationsEnabledOnDevice(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channelId = context.getString(R.string.streams_notification_channel_id) + val manager = context.getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager + val enabled = manager.areNotificationsEnabled() + val channel = manager.getNotificationChannel(channelId) + val importance = channel?.importance + enabled && channel != null && importance != NotificationManager.IMPORTANCE_NONE + } else { + NotificationManagerCompat.from(context).areNotificationsEnabled() + } + } + + /** + * Whether the user enabled the notifications for new streams in the app settings. + */ + @JvmStatic + fun areNewStreamsNotificationsEnabled(context: Context): Boolean { + return ( + PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.enable_streams_notifications), false) && + areNotificationsEnabledOnDevice(context) + ) + } + + /** + * Open the system's notification settings for NewPipe on Android Oreo (API 26) and later. + * Open the system's app settings for NewPipe on previous Android versions. + */ + fun openNewPipeSystemNotificationSettings(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } else { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.data = Uri.parse("package:" + context.packageName) + context.startActivity(intent) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt new file mode 100644 index 000000000..6b9580802 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt @@ -0,0 +1,170 @@ +package org.schabi.newpipe.local.feed.notifications + +import android.content.Context +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.rxjava3.RxWorker +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single +import org.schabi.newpipe.App +import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.ErrorUtil +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.local.feed.service.FeedLoadManager +import org.schabi.newpipe.local.feed.service.FeedLoadService +import java.util.concurrent.TimeUnit + +/* + * Worker which checks for new streams of subscribed channels + * in intervals which can be set by the user in the settings. + */ +class NotificationWorker( + appContext: Context, + workerParams: WorkerParameters, +) : RxWorker(appContext, workerParams) { + + private val notificationHelper by lazy { + NotificationHelper(appContext) + } + private val feedLoadManager = FeedLoadManager(appContext) + + override fun createWork(): Single = if (areNotificationsEnabled(applicationContext)) { + feedLoadManager.startLoading( + ignoreOutdatedThreshold = true, + groupId = FeedLoadManager.GROUP_NOTIFICATION_ENABLED + ) + .doOnSubscribe { showLoadingFeedForegroundNotification() } + .map { feed -> + // filter out feedUpdateInfo items (i.e. channels) with nothing new + feed.mapNotNull { + it.value?.takeIf { feedUpdateInfo -> + feedUpdateInfo.newStreams.isNotEmpty() + } + } + } + .observeOn(AndroidSchedulers.mainThread()) // Picasso requires calls from main thread + .map { feedUpdateInfoList -> + // display notifications for each feedUpdateInfo (i.e. channel) + feedUpdateInfoList.forEach { feedUpdateInfo -> + notificationHelper.displayNewStreamsNotification(feedUpdateInfo) + } + return@map Result.success() + } + .doOnError { throwable -> + Log.e(TAG, "Error while displaying streams notifications", throwable) + ErrorUtil.createNotification( + applicationContext, + ErrorInfo(throwable, UserAction.NEW_STREAMS_NOTIFICATIONS, "main worker") + ) + } + .onErrorReturnItem(Result.failure()) + } else { + // the user can disable streams notifications in the device's app settings + Single.just(Result.success()) + } + + private fun showLoadingFeedForegroundNotification() { + val notification = NotificationCompat.Builder( + applicationContext, + applicationContext.getString(R.string.notification_channel_id) + ).setOngoing(true) + .setProgress(-1, -1, true) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setContentTitle(applicationContext.getString(R.string.feed_notification_loading)) + .build() + setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification)) + } + + companion object { + + private val TAG = NotificationWorker::class.java.simpleName + private const val WORK_TAG = App.PACKAGE_NAME + "_streams_notifications" + + private fun areNotificationsEnabled(context: Context) = + NotificationHelper.areNewStreamsNotificationsEnabled(context) && + NotificationHelper.areNotificationsEnabledOnDevice(context) + + /** + * Schedules a task for the [NotificationWorker] + * if the (device and in-app) notifications are enabled, + * otherwise [cancel]s all scheduled tasks. + */ + @JvmStatic + fun initialize(context: Context) { + if (areNotificationsEnabled(context)) { + schedule(context) + } else { + cancel(context) + } + } + + /** + * @param context the context to use + * @param options configuration options for the scheduler + * @param force Force the scheduler to use the new options + * by replacing the previously used worker. + */ + fun schedule(context: Context, options: ScheduleOptions, force: Boolean = false) { + val constraints = Constraints.Builder() + .setRequiredNetworkType( + if (options.isRequireNonMeteredNetwork) { + NetworkType.UNMETERED + } else { + NetworkType.CONNECTED + } + ).build() + + val request = PeriodicWorkRequest.Builder( + NotificationWorker::class.java, + options.interval, + TimeUnit.MILLISECONDS + ).setConstraints(constraints) + .addTag(WORK_TAG) + .build() + + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork( + WORK_TAG, + if (force) { + ExistingPeriodicWorkPolicy.REPLACE + } else { + ExistingPeriodicWorkPolicy.KEEP + }, + request + ) + } + + @JvmStatic + fun schedule(context: Context) = schedule(context, ScheduleOptions.from(context)) + + /** + * Check for new streams immediately + */ + @JvmStatic + fun runNow(context: Context) { + val request = OneTimeWorkRequestBuilder() + .addTag(WORK_TAG) + .build() + WorkManager.getInstance(context).enqueue(request) + } + + /** + * Cancels all current work related to the [NotificationWorker]. + */ + @JvmStatic + fun cancel(context: Context) { + WorkManager.getInstance(context).cancelAllWorkByTag(WORK_TAG) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/ScheduleOptions.kt b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/ScheduleOptions.kt new file mode 100644 index 000000000..37e8fc39e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/ScheduleOptions.kt @@ -0,0 +1,37 @@ +package org.schabi.newpipe.local.feed.notifications + +import android.content.Context +import androidx.preference.PreferenceManager +import org.schabi.newpipe.R +import java.util.concurrent.TimeUnit + +/** + * Information for the Scheduler which checks for new streams. + * See [NotificationWorker] + */ +data class ScheduleOptions( + val interval: Long, + val isRequireNonMeteredNetwork: Boolean +) { + + companion object { + + fun from(context: Context): ScheduleOptions { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + return ScheduleOptions( + interval = TimeUnit.SECONDS.toMillis( + preferences.getString( + context.getString(R.string.streams_notifications_interval_key), + null + )?.toLongOrNull() ?: context.getString( + R.string.streams_notifications_interval_default + ).toLong() + ), + isRequireNonMeteredNetwork = preferences.getString( + context.getString(R.string.streams_notifications_network_key), + context.getString(R.string.streams_notifications_network_default) + ) == context.getString(R.string.streams_notifications_network_wifi) + ) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt new file mode 100644 index 000000000..fec50a579 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt @@ -0,0 +1,270 @@ +package org.schabi.newpipe.local.feed.service + +import android.content.Context +import androidx.preference.PreferenceManager +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Notification +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.functions.Consumer +import io.reactivex.rxjava3.processors.PublishProcessor +import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.R +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.database.subscription.NotificationMode +import org.schabi.newpipe.extractor.ListInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.local.feed.FeedDatabaseManager +import org.schabi.newpipe.local.subscription.SubscriptionManager +import org.schabi.newpipe.util.ExtractorHelper +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +class FeedLoadManager(private val context: Context) { + + private val subscriptionManager = SubscriptionManager(context) + private val feedDatabaseManager = FeedDatabaseManager(context) + + private val notificationUpdater = PublishProcessor.create() + private val currentProgress = AtomicInteger(-1) + private val maxProgress = AtomicInteger(-1) + private val cancelSignal = AtomicBoolean() + private val feedResultsHolder = FeedResultsHolder() + + val notification: Flowable = notificationUpdater.map { description -> + FeedLoadState(description, maxProgress.get(), currentProgress.get()) + } + + /** + * Start checking for new streams of a subscription group. + * @param groupId The ID of the subscription group to load. When using + * [FeedGroupEntity.GROUP_ALL_ID], all subscriptions are loaded. When using + * [GROUP_NOTIFICATION_ENABLED], only subscriptions with enabled notifications for new streams + * are loaded. Using an id of a group created by the user results in that specific group to be + * loaded. + * @param ignoreOutdatedThreshold When `false`, only subscriptions which have not been updated + * within the `feed_update_threshold` are checked for updates. This threshold can be set by + * the user in the app settings. When `true`, all subscriptions are checked for new streams. + */ + fun startLoading( + groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + ignoreOutdatedThreshold: Boolean = false, + ): Single>> { + val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + val useFeedExtractor = defaultSharedPreferences.getBoolean( + context.getString(R.string.feed_use_dedicated_fetch_method_key), + false + ) + + val outdatedThreshold = if (ignoreOutdatedThreshold) { + OffsetDateTime.now(ZoneOffset.UTC) + } else { + val thresholdOutdatedSeconds = ( + defaultSharedPreferences.getString( + context.getString(R.string.feed_update_threshold_key), + context.getString(R.string.feed_update_threshold_default_value) + ) ?: context.getString(R.string.feed_update_threshold_default_value) + ).toInt() + OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong()) + } + + /** + * subscriptions which have not been updated within the feed updated threshold + */ + val outdatedSubscriptions = when (groupId) { + FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold) + GROUP_NOTIFICATION_ENABLED -> feedDatabaseManager.outdatedSubscriptionsWithNotificationMode( + outdatedThreshold, NotificationMode.ENABLED + ) + else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold) + } + + return outdatedSubscriptions + .take(1) + .doOnNext { + currentProgress.set(0) + maxProgress.set(it.size) + } + .filter { it.isNotEmpty() } + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { + notificationUpdater.onNext("") + broadcastProgress() + } + .observeOn(Schedulers.io()) + .flatMap { Flowable.fromIterable(it) } + .takeWhile { !cancelSignal.get() } + .parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2) + .runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2) + .filter { !cancelSignal.get() } + .map { subscriptionEntity -> + var error: Throwable? = null + try { + // check for and load new streams + // either by using the dedicated feed method or by getting the channel info + val listInfo = if (useFeedExtractor) { + ExtractorHelper + .getFeedInfoFallbackToChannelInfo( + subscriptionEntity.serviceId, + subscriptionEntity.url + ) + .onErrorReturn { + error = it // store error, otherwise wrapped into RuntimeException + throw it + } + .blockingGet() + } else { + ExtractorHelper + .getChannelInfo( + subscriptionEntity.serviceId, + subscriptionEntity.url, + true + ) + .onErrorReturn { + error = it // store error, otherwise wrapped into RuntimeException + throw it + } + .blockingGet() + } as ListInfo + + return@map Notification.createOnNext( + FeedUpdateInfo( + subscriptionEntity, + listInfo + ) + ) + } catch (e: Throwable) { + if (error == null) { + // do this to prevent blockingGet() from wrapping into RuntimeException + error = e + } + + val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}" + val wrapper = + FeedLoadService.RequestException(subscriptionEntity.uid, request, error!!) + return@map Notification.createOnError(wrapper) + } + } + .sequential() + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext(NotificationConsumer()) + .observeOn(Schedulers.io()) + .buffer(BUFFER_COUNT_BEFORE_INSERT) + .doOnNext(DatabaseConsumer()) + .subscribeOn(Schedulers.io()) + .toList() + .flatMap { x -> postProcessFeed().toSingleDefault(x.flatten()) } + } + + fun cancel() { + cancelSignal.set(true) + } + + private fun broadcastProgress() { + FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(currentProgress.get(), maxProgress.get())) + } + + /** + * Keep the feed and the stream tables small + * to reduce loading times when trying to display the feed. + *
+ * Remove streams from the feed which are older than [FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE]. + * Remove streams from the database which are not linked / used by any table. + */ + private fun postProcessFeed() = Completable.fromRunnable { + FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message)) + feedDatabaseManager.removeOrphansOrOlderStreams() + + FeedEventManager.postEvent(FeedEventManager.Event.SuccessResultEvent(feedResultsHolder.itemsErrors)) + }.doOnSubscribe { + currentProgress.set(-1) + maxProgress.set(-1) + + notificationUpdater.onNext(context.getString(R.string.feed_processing_message)) + FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message)) + }.subscribeOn(Schedulers.io()) + + private inner class NotificationConsumer : Consumer> { + override fun accept(item: Notification) { + currentProgress.incrementAndGet() + notificationUpdater.onNext(item.value?.name.orEmpty()) + + broadcastProgress() + } + } + + private inner class DatabaseConsumer : Consumer>> { + + override fun accept(list: List>) { + feedDatabaseManager.database().runInTransaction { + for (notification in list) { + when { + notification.isOnNext -> { + val subscriptionId = notification.value!!.uid + val info = notification.value!!.listInfo + + notification.value!!.newStreams = filterNewStreams( + notification.value!!.listInfo.relatedItems + ) + + feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems) + subscriptionManager.updateFromInfo(subscriptionId, info) + + if (info.errors.isNotEmpty()) { + feedResultsHolder.addErrors( + FeedLoadService.RequestException.wrapList( + subscriptionId, + info + ) + ) + feedDatabaseManager.markAsOutdated(subscriptionId) + } + } + notification.isOnError -> { + val error = notification.error + feedResultsHolder.addError(error!!) + + if (error is FeedLoadService.RequestException) { + feedDatabaseManager.markAsOutdated(error.subscriptionId) + } + } + } + } + } + } + + private fun filterNewStreams(list: List): List { + return list.filter { + !feedDatabaseManager.doesStreamExist(it) && + it.uploadDate != null && + // Streams older than this date are automatically removed from the feed. + // Therefore, streams which are not in the database, + // but older than this date, are considered old. + it.uploadDate!!.offsetDateTime().isAfter( + FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE + ) + } + } + } + + companion object { + + /** + * Constant used to check for updates of subscriptions with [NotificationMode.ENABLED]. + */ + const val GROUP_NOTIFICATION_ENABLED = -2L + + /** + * How many extractions will be running in parallel. + */ + private const val PARALLEL_EXTRACTIONS = 6 + + /** + * Number of items to buffer to mass-insert in the database. + */ + private const val BUFFER_COUNT_BEFORE_INSERT = 20 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt index 5bc097fe5..f2ea40416 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt @@ -31,41 +31,24 @@ import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.ServiceCompat -import androidx.preference.PreferenceManager import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.core.Notification -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.functions.Consumer +import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.functions.Function -import io.reactivex.rxjava3.processors.PublishProcessor -import io.reactivex.rxjava3.schedulers.Schedulers -import org.reactivestreams.Subscriber -import org.reactivestreams.Subscription import org.schabi.newpipe.App import org.schabi.newpipe.MainActivity.DEBUG import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.extractor.ListInfo import org.schabi.newpipe.extractor.stream.StreamInfoItem -import org.schabi.newpipe.local.feed.FeedDatabaseManager import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent -import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ProgressEvent -import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.SuccessResultEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent -import org.schabi.newpipe.local.subscription.SubscriptionManager -import org.schabi.newpipe.util.ExtractorHelper -import java.time.OffsetDateTime -import java.time.ZoneOffset import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicInteger class FeedLoadService : Service() { companion object { private val TAG = FeedLoadService::class.java.simpleName - private const val NOTIFICATION_ID = 7293450 + const val NOTIFICATION_ID = 7293450 private const val ACTION_CANCEL = App.PACKAGE_NAME + ".local.feed.service.FeedLoadService.CANCEL" /** @@ -73,27 +56,13 @@ class FeedLoadService : Service() { */ private const val NOTIFICATION_SAMPLING_PERIOD = 1500 - /** - * How many extractions will be running in parallel. - */ - private const val PARALLEL_EXTRACTIONS = 6 - - /** - * Number of items to buffer to mass-insert in the database. - */ - private const val BUFFER_COUNT_BEFORE_INSERT = 20 - const val EXTRA_GROUP_ID: String = "FeedLoadService.EXTRA_GROUP_ID" } - private var loadingSubscription: Subscription? = null - private lateinit var subscriptionManager: SubscriptionManager + private var loadingDisposable: Disposable? = null + private var notificationDisposable: Disposable? = null - private lateinit var feedDatabaseManager: FeedDatabaseManager - private lateinit var feedResultsHolder: ResultsHolder - - private var disposables = CompositeDisposable() - private var notificationUpdater = PublishProcessor.create() + private lateinit var feedLoadManager: FeedLoadManager // ///////////////////////////////////////////////////////////////////////// // Lifecycle @@ -101,8 +70,7 @@ class FeedLoadService : Service() { override fun onCreate() { super.onCreate() - subscriptionManager = SubscriptionManager(this) - feedDatabaseManager = FeedDatabaseManager(this) + feedLoadManager = FeedLoadManager(this) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -114,40 +82,45 @@ class FeedLoadService : Service() { ) } - if (intent == null || loadingSubscription != null) { + if (intent == null || loadingDisposable != null) { return START_NOT_STICKY } setupNotification() setupBroadcastReceiver() - val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) val groupId = intent.getLongExtra(EXTRA_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID) - val useFeedExtractor = defaultSharedPreferences - .getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false) - - val thresholdOutdatedSecondsString = defaultSharedPreferences - .getString(getString(R.string.feed_update_threshold_key), getString(R.string.feed_update_threshold_default_value)) - val thresholdOutdatedSeconds = thresholdOutdatedSecondsString!!.toInt() - - startLoading(groupId, useFeedExtractor, thresholdOutdatedSeconds) - + loadingDisposable = feedLoadManager.startLoading(groupId) + .observeOn(AndroidSchedulers.mainThread()) + .doOnSubscribe { + startForeground(NOTIFICATION_ID, notificationBuilder.build()) + } + .subscribe { _, error -> + // There seems to be a bug in the kotlin plugin as it tells you when + // building that this can't be null: + // "Condition 'error != null' is always 'true'" + // However it can indeed be null + // The suppression may be removed in further versions + @Suppress("SENSELESS_COMPARISON") + if (error != null) { + Log.e(TAG, "Error while storing result", error) + handleError(error) + return@subscribe + } + stopService() + } return START_NOT_STICKY } private fun disposeAll() { unregisterReceiver(broadcastReceiver) - - loadingSubscription?.cancel() - loadingSubscription = null - - disposables.dispose() + loadingDisposable?.dispose() + notificationDisposable?.dispose() } private fun stopService() { disposeAll() ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) - notificationManager.cancel(NOTIFICATION_ID) stopSelf() } @@ -171,182 +144,6 @@ class FeedLoadService : Service() { } } - private fun startLoading(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, useFeedExtractor: Boolean, thresholdOutdatedSeconds: Int) { - feedResultsHolder = ResultsHolder() - - val outdatedThreshold = OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong()) - - val subscriptions = when (groupId) { - FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold) - else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold) - } - - subscriptions - .take(1) - .doOnNext { - currentProgress.set(0) - maxProgress.set(it.size) - } - .filter { it.isNotEmpty() } - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext { - startForeground(NOTIFICATION_ID, notificationBuilder.build()) - updateNotificationProgress(null) - broadcastProgress() - } - .observeOn(Schedulers.io()) - .flatMap { Flowable.fromIterable(it) } - .takeWhile { !cancelSignal.get() } - .parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2) - .runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2) - .filter { !cancelSignal.get() } - .map { subscriptionEntity -> - var error: Throwable? = null - try { - val listInfo = if (useFeedExtractor) { - ExtractorHelper - .getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url) - .onErrorReturn { - error = it // store error, otherwise wrapped into RuntimeException - throw it - } - .blockingGet() - } else { - ExtractorHelper - .getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true) - .onErrorReturn { - error = it // store error, otherwise wrapped into RuntimeException - throw it - } - .blockingGet() - } as ListInfo - - return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo)) - } catch (e: Throwable) { - if (error == null) { - // do this to prevent blockingGet() from wrapping into RuntimeException - error = e - } - - val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}" - val wrapper = RequestException(subscriptionEntity.uid, request, error!!) - return@map Notification.createOnError>>(wrapper) - } - } - .sequential() - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext(notificationsConsumer) - .observeOn(Schedulers.io()) - .buffer(BUFFER_COUNT_BEFORE_INSERT) - .doOnNext(databaseConsumer) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(resultSubscriber) - } - - private fun broadcastProgress() { - postEvent(ProgressEvent(currentProgress.get(), maxProgress.get())) - } - - private val resultSubscriber - get() = object : Subscriber>>>> { - - override fun onSubscribe(s: Subscription) { - loadingSubscription = s - s.request(java.lang.Long.MAX_VALUE) - } - - override fun onNext(notification: List>>>) { - if (DEBUG) Log.v(TAG, "onNext() → $notification") - } - - override fun onError(error: Throwable) { - handleError(error) - } - - override fun onComplete() { - if (maxProgress.get() == 0) { - postEvent(FeedEventManager.Event.IdleEvent) - stopService() - - return - } - - currentProgress.set(-1) - maxProgress.set(-1) - - notificationUpdater.onNext(getString(R.string.feed_processing_message)) - postEvent(ProgressEvent(R.string.feed_processing_message)) - - disposables.add( - Single - .fromCallable { - feedResultsHolder.ready() - - postEvent(ProgressEvent(R.string.feed_processing_message)) - feedDatabaseManager.removeOrphansOrOlderStreams() - - postEvent(SuccessResultEvent(feedResultsHolder.itemsErrors)) - true - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { _, throwable -> - // There seems to be a bug in the kotlin plugin as it tells you when - // building that this can't be null: - // "Condition 'throwable != null' is always 'true'" - // However it can indeed be null - // The suppression may be removed in further versions - @Suppress("SENSELESS_COMPARISON") - if (throwable != null) { - Log.e(TAG, "Error while storing result", throwable) - handleError(throwable) - return@subscribe - } - stopService() - } - ) - } - } - - private val databaseConsumer: Consumer>>>> - get() = Consumer { - feedDatabaseManager.database().runInTransaction { - for (notification in it) { - - if (notification.isOnNext) { - val subscriptionId = notification.value!!.first - val info = notification.value!!.second - - feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems) - subscriptionManager.updateFromInfo(subscriptionId, info) - - if (info.errors.isNotEmpty()) { - feedResultsHolder.addErrors(RequestException.wrapList(subscriptionId, info)) - feedDatabaseManager.markAsOutdated(subscriptionId) - } - } else if (notification.isOnError) { - val error = notification.error!! - feedResultsHolder.addError(error) - - if (error is RequestException) { - feedDatabaseManager.markAsOutdated(error.subscriptionId) - } - } - } - } - } - - private val notificationsConsumer: Consumer>>> - get() = Consumer { onItemCompleted(it.value?.second?.name) } - - private fun onItemCompleted(updateDescription: String?) { - currentProgress.incrementAndGet() - notificationUpdater.onNext(updateDescription ?: "") - - broadcastProgress() - } - // ///////////////////////////////////////////////////////////////////////// // Notification // ///////////////////////////////////////////////////////////////////////// @@ -354,13 +151,12 @@ class FeedLoadService : Service() { private lateinit var notificationManager: NotificationManagerCompat private lateinit var notificationBuilder: NotificationCompat.Builder - private var currentProgress = AtomicInteger(-1) - private var maxProgress = AtomicInteger(-1) - private fun createNotification(): NotificationCompat.Builder { val cancelActionIntent = PendingIntent.getBroadcast( this, - NOTIFICATION_ID, Intent(ACTION_CANCEL), 0 + NOTIFICATION_ID, + Intent(ACTION_CANCEL), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 ) return NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) @@ -376,33 +172,36 @@ class FeedLoadService : Service() { notificationManager = NotificationManagerCompat.from(this) notificationBuilder = createNotification() - val throttleAfterFirstEmission = Function { flow: Flowable -> + val throttleAfterFirstEmission = Function { flow: Flowable -> flow.take(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS)) } - disposables.add( - notificationUpdater - .publish(throttleAfterFirstEmission) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::updateNotificationProgress) - ) + notificationDisposable = feedLoadManager.notification + .publish(throttleAfterFirstEmission) + .observeOn(AndroidSchedulers.mainThread()) + .doOnTerminate { notificationManager.cancel(NOTIFICATION_ID) } + .subscribe(this::updateNotificationProgress) } - private fun updateNotificationProgress(updateDescription: String?) { - notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1) + private fun updateNotificationProgress(state: FeedLoadState) { + notificationBuilder.setProgress(state.maxProgress, state.currentProgress, state.maxProgress == -1) - if (maxProgress.get() == -1) { + if (state.maxProgress == -1) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null) - if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription) - notificationBuilder.setContentText(updateDescription) + if (state.updateDescription.isNotEmpty()) notificationBuilder.setContentText(state.updateDescription) + notificationBuilder.setContentText(state.updateDescription) } else { - val progressText = this.currentProgress.toString() + "/" + maxProgress + val progressText = state.currentProgress.toString() + "/" + state.maxProgress if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText("$updateDescription ($progressText)") + if (state.updateDescription.isNotEmpty()) { + notificationBuilder.setContentText("${state.updateDescription} ($progressText)") + } } else { notificationBuilder.setContentInfo(progressText) - if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription) + if (state.updateDescription.isNotEmpty()) { + notificationBuilder.setContentText(state.updateDescription) + } } } @@ -414,13 +213,12 @@ class FeedLoadService : Service() { // ///////////////////////////////////////////////////////////////////////// private lateinit var broadcastReceiver: BroadcastReceiver - private val cancelSignal = AtomicBoolean() private fun setupBroadcastReceiver() { broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == ACTION_CANCEL) { - cancelSignal.set(true) + feedLoadManager.cancel() } } } @@ -435,29 +233,4 @@ class FeedLoadService : Service() { postEvent(ErrorResultEvent(error)) stopService() } - - // ///////////////////////////////////////////////////////////////////////// - // Results Holder - // ///////////////////////////////////////////////////////////////////////// - - class ResultsHolder { - /** - * List of errors that may have happen during loading. - */ - internal lateinit var itemsErrors: List - - private val itemsErrorsHolder: MutableList = ArrayList() - - fun addError(error: Throwable) { - itemsErrorsHolder.add(error) - } - - fun addErrors(errors: List) { - itemsErrorsHolder.addAll(errors) - } - - fun ready() { - itemsErrors = itemsErrorsHolder.toList() - } - } } diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadState.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadState.kt new file mode 100644 index 000000000..703f593ad --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadState.kt @@ -0,0 +1,7 @@ +package org.schabi.newpipe.local.feed.service + +data class FeedLoadState( + val updateDescription: String, + val maxProgress: Int, + val currentProgress: Int, +) diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedResultsHolder.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedResultsHolder.kt new file mode 100644 index 000000000..729f2c009 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedResultsHolder.kt @@ -0,0 +1,19 @@ +package org.schabi.newpipe.local.feed.service + +class FeedResultsHolder { + /** + * List of errors that may have happen during loading. + */ + val itemsErrors: List + get() = itemsErrorsHolder + + private val itemsErrorsHolder: MutableList = ArrayList() + + fun addError(error: Throwable) { + itemsErrorsHolder.add(error) + } + + fun addErrors(errors: List) { + itemsErrorsHolder.addAll(errors) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt new file mode 100644 index 000000000..5f72a6b84 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt @@ -0,0 +1,34 @@ +package org.schabi.newpipe.local.feed.service + +import org.schabi.newpipe.database.subscription.NotificationMode +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import org.schabi.newpipe.extractor.ListInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem + +data class FeedUpdateInfo( + val uid: Long, + @NotificationMode + val notificationMode: Int, + val name: String, + val avatarUrl: String, + val listInfo: ListInfo, +) { + constructor( + subscription: SubscriptionEntity, + listInfo: ListInfo, + ) : this( + uid = subscription.uid, + notificationMode = subscription.notificationMode, + name = subscription.name, + avatarUrl = subscription.avatarUrl, + listInfo = listInfo, + ) + + /** + * Integer id, can be used as notification id, etc. + */ + val pseudoId: Int + get() = listInfo.url.hashCode() + + lateinit var newStreams: List +} diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java index e7ccd07d2..709a16b68 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java @@ -84,7 +84,7 @@ public abstract class HistoryEntryAdapter } @Override - public void onViewRecycled(final VH holder) { + public void onViewRecycled(@NonNull final VH holder) { super.onViewRecycled(holder); holder.itemView.setOnClickListener(null); } diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java index 73682d5d5..01df34292 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java @@ -1,6 +1,5 @@ package org.schabi.newpipe.local.history; -import android.app.Activity; import android.content.Context; import android.os.Bundle; import android.os.Parcelable; @@ -29,20 +28,16 @@ import org.schabi.newpipe.databinding.StatisticPlaylistControlBinding; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.info_list.InfoItemDialog; +import org.schabi.newpipe.info_list.dialog.InfoItemDialog; import org.schabi.newpipe.local.BaseLocalListFragment; -import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.settings.HistorySettingsFragment; -import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; -import org.schabi.newpipe.util.StreamDialogEntry; +import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.List; @@ -154,7 +149,7 @@ public class StatisticsPlaylistFragment @Override public void held(final LocalItem selectedItem) { if (selectedItem instanceof StreamStatisticsEntry) { - showStreamDialog((StreamStatisticsEntry) selectedItem); + showInfoItemDialog((StreamStatisticsEntry) selectedItem); } } }); @@ -328,66 +323,30 @@ public class StatisticsPlaylistFragment return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0)); } - private void showStreamDialog(final StreamStatisticsEntry item) { + private void showInfoItemDialog(final StreamStatisticsEntry item) { final Context context = getContext(); - final Activity activity = getActivity(); - if (context == null || context.getResources() == null || activity == null) { - return; - } final StreamInfoItem infoItem = item.toStreamInfoItem(); - final ArrayList entries = new ArrayList<>(); + try { + final InfoItemDialog.Builder dialogBuilder = + new InfoItemDialog.Builder(getActivity(), context, this, infoItem); - if (PlayerHolder.getInstance().isPlayQueueReady()) { - entries.add(StreamDialogEntry.enqueue); - - if (PlayerHolder.getInstance().getQueueSize() > 1) { - entries.add(StreamDialogEntry.enqueue_next); - } + // set entries in the middle; the others are added automatically + dialogBuilder + .addEntry(StreamDialogDefaultEntry.DELETE) + .setAction( + StreamDialogDefaultEntry.DELETE, + (f, i) -> deleteEntry( + Math.max(itemListAdapter.getItemsList().indexOf(item), 0))) + .setAction( + StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND, + (f, i) -> NavigationHelper.playOnBackgroundPlayer( + context, getPlayQueueStartingAt(item), true)) + .create() + .show(); + } catch (final IllegalArgumentException e) { + InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem); } - - if (infoItem.getStreamType() == StreamType.AUDIO_STREAM) { - entries.addAll(Arrays.asList( - StreamDialogEntry.start_here_on_background, - StreamDialogEntry.delete, - StreamDialogEntry.append_playlist, - StreamDialogEntry.share - )); - } else { - entries.addAll(Arrays.asList( - StreamDialogEntry.start_here_on_background, - StreamDialogEntry.start_here_on_popup, - StreamDialogEntry.delete, - StreamDialogEntry.append_playlist, - StreamDialogEntry.share - )); - } - entries.add(StreamDialogEntry.open_in_browser); - if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { - entries.add(StreamDialogEntry.play_with_kodi); - } - - // show "mark as watched" only when watch history is enabled - if (StreamDialogEntry.shouldAddMarkAsWatched( - item.getStreamEntity().getStreamType(), - context - )) { - entries.add( - StreamDialogEntry.mark_as_watched - ); - } - entries.add(StreamDialogEntry.show_channel_details); - - StreamDialogEntry.setEnabledEntries(entries); - - StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItemDuplicate) -> - NavigationHelper - .playOnBackgroundPlayer(context, getPlayQueueStartingAt(item), true)); - StreamDialogEntry.delete.setCustomAction((fragment, infoItemDuplicate) -> - deleteEntry(Math.max(itemListAdapter.getItemsList().indexOf(item), 0))); - - new InfoItemDialog(activity, infoItem, StreamDialogEntry.getCommands(context), - (dialog, which) -> StreamDialogEntry.clickOn(which, this, infoItem)).show(); } private void deleteEntry(final int index) { diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java index feb5b2f96..0eb56d716 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java @@ -1,6 +1,8 @@ package org.schabi.newpipe.local.playlist; -import android.app.Activity; +import static org.schabi.newpipe.ktx.ViewUtils.animate; +import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout; + import android.content.Context; import android.content.DialogInterface; import android.os.Bundle; @@ -38,22 +40,18 @@ import org.schabi.newpipe.databinding.PlaylistControlBinding; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.info_list.InfoItemDialog; +import org.schabi.newpipe.info_list.dialog.InfoItemDialog; import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.player.MainPlayer.PlayerType; -import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; -import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; -import org.schabi.newpipe.util.StreamDialogEntry; +import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.Iterator; import java.util.List; @@ -68,9 +66,6 @@ import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.subjects.PublishSubject; -import static org.schabi.newpipe.ktx.ViewUtils.animate; -import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout; - public class LocalPlaylistFragment extends BaseLocalListFragment, Void> { // Save the list 10 seconds after the last change occurred private static final long SAVE_DEBOUNCE_MILLIS = 10000; @@ -182,7 +177,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment removeWatchedStreams(false)) .setNeutralButton( R.string.remove_watched_popup_yes_and_partially_watched_videos, @@ -743,70 +738,39 @@ public class LocalPlaylistFragment extends BaseLocalListFragment entries = new ArrayList<>(); + try { + final Context context = getContext(); + final InfoItemDialog.Builder dialogBuilder = + new InfoItemDialog.Builder(getActivity(), context, this, infoItem); - if (PlayerHolder.getInstance().isPlayQueueReady()) { - entries.add(StreamDialogEntry.enqueue); - - if (PlayerHolder.getInstance().getQueueSize() > 1) { - entries.add(StreamDialogEntry.enqueue_next); - } - } - if (infoItem.getStreamType() == StreamType.AUDIO_STREAM) { - entries.addAll(Arrays.asList( - StreamDialogEntry.start_here_on_background, - StreamDialogEntry.set_as_playlist_thumbnail, - StreamDialogEntry.delete, - StreamDialogEntry.append_playlist, - StreamDialogEntry.share - )); - } else { - entries.addAll(Arrays.asList( - StreamDialogEntry.start_here_on_background, - StreamDialogEntry.start_here_on_popup, - StreamDialogEntry.set_as_playlist_thumbnail, - StreamDialogEntry.delete, - StreamDialogEntry.append_playlist, - StreamDialogEntry.share - )); - } - entries.add(StreamDialogEntry.open_in_browser); - if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { - entries.add(StreamDialogEntry.play_with_kodi); - } - - // show "mark as watched" only when watch history is enabled - if (StreamDialogEntry.shouldAddMarkAsWatched( - item.getStreamEntity().getStreamType(), - context - )) { - entries.add( - StreamDialogEntry.mark_as_watched + // add entries in the middle + dialogBuilder.addAllEntries( + StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL, + StreamDialogDefaultEntry.DELETE ); + + // set custom actions + // all entries modified below have already been added within the builder + dialogBuilder + .setAction( + StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND, + (f, i) -> NavigationHelper.playOnBackgroundPlayer( + context, getPlayQueueStartingAt(item), true)) + .setAction( + StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL, + (f, i) -> + changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl())) + .setAction( + StreamDialogDefaultEntry.DELETE, + (f, i) -> deleteItem(item)) + .create() + .show(); + } catch (final IllegalArgumentException e) { + InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem); } - entries.add(StreamDialogEntry.show_channel_details); - - StreamDialogEntry.setEnabledEntries(entries); - - StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItemDuplicate) -> - NavigationHelper.playOnBackgroundPlayer(context, - getPlayQueueStartingAt(item), true)); - StreamDialogEntry.set_as_playlist_thumbnail.setCustomAction( - (fragment, infoItemDuplicate) -> - changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl())); - StreamDialogEntry.delete.setCustomAction((fragment, infoItemDuplicate) -> - deleteItem(item)); - - new InfoItemDialog(activity, infoItem, StreamDialogEntry.getCommands(context), - (dialog, which) -> StreamDialogEntry.clickOn(which, this, infoItem)).show(); } private void setInitialData(final long pid, final String title) { diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java index a3d8b0567..da8e1070a 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java @@ -61,7 +61,7 @@ public class ImportConfirmationDialog extends DialogFragment { } @Override - public void onSaveInstanceState(final Bundle outState) { + public void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); Icepick.saveInstanceState(this, outState); } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt index fb9cffa98..b17f49801 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt @@ -7,6 +7,8 @@ import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.SubscriptionDAO import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.extractor.ListInfo @@ -14,6 +16,7 @@ import org.schabi.newpipe.extractor.channel.ChannelInfo import org.schabi.newpipe.extractor.feed.FeedInfo import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.local.feed.FeedDatabaseManager +import org.schabi.newpipe.util.ExtractorHelper class SubscriptionManager(context: Context) { private val database = NewPipeDatabase.getInstance(context) @@ -66,13 +69,33 @@ class SubscriptionManager(context: Context) { } } + fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable { + return subscriptionTable().getSubscription(serviceId, url) + .flatMapCompletable { entity: SubscriptionEntity -> + Completable.fromAction { + entity.notificationMode = mode + subscriptionTable().update(entity) + }.apply { + if (mode != NotificationMode.DISABLED) { + // notifications have just been enabled, mark all streams as "old" + andThen(rememberAllStreams(entity)) + } + } + } + } + fun updateFromInfo(subscriptionId: Long, info: ListInfo) { val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId) if (info is FeedInfo) { subscriptionEntity.name = info.name } else if (info is ChannelInfo) { - subscriptionEntity.setData(info.name, info.avatarUrl, info.description, info.subscriberCount) + subscriptionEntity.setData( + info.name, + info.avatarUrl, + info.description, + info.subscriberCount + ) } subscriptionTable.update(subscriptionEntity) @@ -94,4 +117,19 @@ class SubscriptionManager(context: Context) { fun deleteSubscription(subscriptionEntity: SubscriptionEntity) { subscriptionTable.delete(subscriptionEntity) } + + /** + * Fetches the list of videos for the provided channel and saves them in the database, so that + * they will be considered as "old"/"already seen" streams and the user will never be notified + * about any one of them. + */ + private fun rememberAllStreams(subscription: SubscriptionEntity): Completable { + return ExtractorHelper.getChannelInfo(subscription.serviceId, subscription.url, false) + .map { channel -> channel.relatedItems.map { stream -> StreamEntity(stream) } } + .flatMapCompletable { entities -> + Completable.fromAction { + database.streamDAO().upsertAll(entities) + } + }.onErrorComplete() + } } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt index d49df6303..54ba1c6dc 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt @@ -8,7 +8,6 @@ import androidx.lifecycle.ViewModelProvider import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.disposables.Disposable -import io.reactivex.rxjava3.functions.BiFunction import io.reactivex.rxjava3.processors.BehaviorProcessor import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.database.feed.model.FeedGroupEntity @@ -33,9 +32,8 @@ class FeedGroupDialogViewModel( private var subscriptionsFlowable = Flowable .combineLatest( filterSubscriptions.startWithItem(initialQuery), - toggleShowOnlyUngrouped.startWithItem(initialShowOnlyUngrouped), - BiFunction { t1: String, t2: Boolean -> Filter(t1, t2) } - ) + toggleShowOnlyUngrouped.startWithItem(initialShowOnlyUngrouped) + ) { t1: String, t2: Boolean -> Filter(t1, t2) } .distinctUntilChanged() .switchMap { (query, showOnlyUngrouped) -> subscriptionManager.getSubscriptions(groupId, query, showOnlyUngrouped) @@ -56,9 +54,8 @@ class FeedGroupDialogViewModel( private var subscriptionsDisposable = Flowable .combineLatest( - subscriptionsFlowable, feedDatabaseManager.subscriptionIdsForGroup(groupId), - BiFunction { t1: List, t2: List -> t1 to t2.toSet() } - ) + subscriptionsFlowable, feedDatabaseManager.subscriptionIdsForGroup(groupId) + ) { t1: List, t2: List -> t1 to t2.toSet() } .subscribeOn(Schedulers.io()) .subscribe(mutableSubscriptionsLiveData::postValue) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt index 50e8aae6a..1f3ab71eb 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt @@ -57,15 +57,12 @@ class FeedGroupReorderDialog : DialogFragment() { viewModel = ViewModelProvider(this).get(FeedGroupReorderDialogViewModel::class.java) viewModel.groupsLiveData.observe(viewLifecycleOwner, Observer(::handleGroups)) - viewModel.dialogEventLiveData.observe( - viewLifecycleOwner, - Observer { - when (it) { - ProcessingEvent -> disableInput() - SuccessEvent -> dismiss() - } + viewModel.dialogEventLiveData.observe(viewLifecycleOwner) { + when (it) { + ProcessingEvent -> disableInput() + SuccessEvent -> dismiss() } - ) + } binding.feedGroupsList.layoutManager = LinearLayoutManager(requireContext()) binding.feedGroupsList.adapter = groupAdapter diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java index 8e3aad893..611a1cd30 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java @@ -25,7 +25,6 @@ import com.grack.nanojson.JsonAppendableWriter; import com.grack.nanojson.JsonArray; import com.grack.nanojson.JsonObject; import com.grack.nanojson.JsonParser; -import com.grack.nanojson.JsonSink; import com.grack.nanojson.JsonWriter; import org.schabi.newpipe.BuildConfig; @@ -125,10 +124,11 @@ public final class ImportExportJsonHelper { /** * @see #writeTo(List, OutputStream, ImportExportEventListener) * @param items the list of subscriptions items - * @param writer the output {@link JsonSink} + * @param writer the output {@link JsonAppendableWriter} * @param eventListener listener for the events generated */ - public static void writeTo(final List items, final JsonSink writer, + public static void writeTo(final List items, + final JsonAppendableWriter writer, @Nullable final ImportExportEventListener eventListener) { if (eventListener != null) { eventListener.onSizeReceived(items.size()); diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java index e0c5ab083..53e6ce591 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java @@ -1,7 +1,10 @@ package org.schabi.newpipe.player; +import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu; +import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; +import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; + import android.content.ComponentName; -import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; import android.os.Bundle; @@ -23,11 +26,9 @@ import androidx.recyclerview.widget.RecyclerView; import com.google.android.exoplayer2.PlaybackParameters; import org.schabi.newpipe.R; -import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.databinding.ActivityPlayerQueueControlBinding; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; -import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.player.event.PlayerEventListener; import org.schabi.newpipe.player.helper.PlaybackParameterDialog; import org.schabi.newpipe.player.playqueue.PlayQueue; @@ -42,13 +43,6 @@ import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.ThemeHelper; -import java.util.List; -import java.util.stream.Collectors; - -import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu; -import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - public final class PlayQueueActivity extends AppCompatActivity implements PlayerEventListener, SeekBar.OnSeekBarChangeListener, View.OnClickListener, PlaybackParameterDialog.Callback { @@ -129,7 +123,7 @@ public final class PlayQueueActivity extends AppCompatActivity NavigationHelper.openSettings(this); return true; case R.id.action_append_playlist: - appendAllToPlaylist(); + player.onAddToPlaylistClicked(getSupportFragmentManager()); return true; case R.id.action_playback_speed: openPlaybackParameterDialog(); @@ -443,24 +437,6 @@ public final class PlayQueueActivity extends AppCompatActivity seeking = false; } - //////////////////////////////////////////////////////////////////////////// - // Playlist append - //////////////////////////////////////////////////////////////////////////// - - private void appendAllToPlaylist() { - if (player != null && player.getPlayQueue() != null) { - openPlaylistAppendDialog(player.getPlayQueue().getStreams()); - } - } - - private void openPlaylistAppendDialog(final List playQueueItems) { - PlaylistDialog.createCorrespondingDialog( - getApplicationContext(), - playQueueItems.stream().map(StreamEntity::new).collect(Collectors.toList()), - dialog -> dialog.show(getSupportFragmentManager(), TAG) - ); - } - //////////////////////////////////////////////////////////////////////////// // Binding Service Listener //////////////////////////////////////////////////////////////////////////// @@ -624,7 +600,6 @@ public final class PlayQueueActivity extends AppCompatActivity //2) Icon change accordingly to current App Theme // using rootView.getContext() because getApplicationContext() didn't work - final Context context = queueControlBinding.getRoot().getContext(); item.setIcon(player.isMuted() ? R.drawable.ic_volume_off : R.drawable.ic_volume_up); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 87d2bd34a..30c62af39 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -1,5 +1,21 @@ package org.schabi.newpipe.player; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_NO_PERMISSION; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_UNSPECIFIED; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_TIMEOUT; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_UNSPECIFIED; import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_AUTO_TRANSITION; import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_INTERNAL; import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_REMOVE; @@ -46,7 +62,6 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.savePlaybackParamete import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex; import static org.schabi.newpipe.util.ListHelper.getResolutionIndex; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; -import static org.schabi.newpipe.util.Localization.containsCaseInsensitive; import static java.util.concurrent.TimeUnit.MILLISECONDS; import android.animation.Animator; @@ -73,7 +88,6 @@ import android.provider.Settings; import android.util.DisplayMetrics; import android.util.Log; import android.util.TypedValue; -import android.view.ContextThemeWrapper; import android.view.Gravity; import android.view.KeyEvent; import android.view.LayoutInflater; @@ -89,7 +103,6 @@ import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; -import android.widget.PopupMenu; import android.widget.ProgressBar; import android.widget.RelativeLayout; import android.widget.SeekBar; @@ -99,30 +112,34 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.view.ContextThemeWrapper; import androidx.appcompat.widget.AppCompatImageButton; +import androidx.appcompat.widget.PopupMenu; +import androidx.collection.ArraySet; import androidx.core.content.ContextCompat; import androidx.core.graphics.Insets; import androidx.core.view.GestureDetectorCompat; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; +import androidx.fragment.app.FragmentManager; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.DefaultRenderersFactory; -import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.PlaybackException; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player.PositionInfo; import com.google.android.exoplayer2.RenderersFactory; -import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.source.BehindLiveWindowException; +import com.google.android.exoplayer2.TracksInfo; import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.TrackGroup; -import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.text.Cue; -import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.MappingTrackSelector; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; import com.google.android.exoplayer2.ui.CaptionStyleCompat; import com.google.android.exoplayer2.ui.SubtitleView; @@ -136,19 +153,23 @@ import com.squareup.picasso.Target; import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; +import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.databinding.PlayerBinding; import org.schabi.newpipe.databinding.PlayerPopupCloseOverlayBinding; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.extractor.Info; import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamSegment; +import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; import org.schabi.newpipe.fragments.detail.VideoDetailFragment; import org.schabi.newpipe.info_list.StreamSegmentAdapter; import org.schabi.newpipe.ktx.AnimationType; +import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.player.MainPlayer.PlayerType; import org.schabi.newpipe.player.event.DisplayPortion; @@ -158,10 +179,11 @@ import org.schabi.newpipe.player.event.PlayerServiceEventListener; import org.schabi.newpipe.player.helper.AudioReactor; import org.schabi.newpipe.player.helper.LoadController; import org.schabi.newpipe.player.helper.MediaSessionManager; -import org.schabi.newpipe.player.helper.PlaybackParameterDialog; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.helper.PlayerHelper; -import org.schabi.newpipe.player.playback.CustomTrackSelector; +import org.schabi.newpipe.player.listeners.view.PlaybackSpeedClickListener; +import org.schabi.newpipe.player.listeners.view.QualityClickListener; +import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.playback.MediaSourceManager; import org.schabi.newpipe.player.playback.PlaybackListener; import org.schabi.newpipe.player.playback.PlayerMediaSession; @@ -173,8 +195,8 @@ import org.schabi.newpipe.player.playqueue.PlayQueueItemBuilder; import org.schabi.newpipe.player.playqueue.PlayQueueItemHolder; import org.schabi.newpipe.player.playqueue.PlayQueueItemTouchCallback; import org.schabi.newpipe.player.resolver.AudioPlaybackResolver; -import org.schabi.newpipe.player.resolver.MediaSourceTag; import org.schabi.newpipe.player.resolver.VideoPlaybackResolver; +import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.SourceType; import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper; import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHolder; import org.schabi.newpipe.util.DeviceUtils; @@ -188,11 +210,12 @@ import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.views.ExpandableSurfaceView; import org.schabi.newpipe.views.player.PlayerFastSeekOverlay; -import java.io.IOException; -import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; @@ -268,19 +291,19 @@ public final class Player implements @Nullable private MediaSourceManager playQueueManager; @Nullable private PlayQueueItem currentItem; - @Nullable private MediaSourceTag currentMetadata; + @Nullable private MediaItemTag currentMetadata; @Nullable private Bitmap currentThumbnail; /*////////////////////////////////////////////////////////////////////////// // Player //////////////////////////////////////////////////////////////////////////*/ - private SimpleExoPlayer simpleExoPlayer; + private ExoPlayer simpleExoPlayer; private AudioReactor audioReactor; private MediaSessionManager mediaSessionManager; @Nullable private SurfaceHolderCallback surfaceHolderCallback; - @NonNull private final CustomTrackSelector trackSelector; + @NonNull private final DefaultTrackSelector trackSelector; @NonNull private final LoadController loadController; @NonNull private final RenderersFactory renderFactory; @@ -405,7 +428,7 @@ public final class Player implements setupBroadcastReceiver(); - trackSelector = new CustomTrackSelector(context, PlayerHelper.getQualitySelector()); + trackSelector = new DefaultTrackSelector(context, PlayerHelper.getQualitySelector()); final PlayerDataSource dataSource = new PlayerDataSource(context, DownloaderImpl.USER_AGENT, new DefaultBandwidthMeter.Builder(context).build()); loadController = new LoadController(); @@ -488,7 +511,7 @@ public final class Player implements Log.d(TAG, "initPlayer() called with: playOnReady = [" + playOnReady + "]"); } - simpleExoPlayer = new SimpleExoPlayer.Builder(context, renderFactory) + simpleExoPlayer = new ExoPlayer.Builder(context, renderFactory) .setTrackSelector(trackSelector) .setLoadControl(loadController) .build(); @@ -521,9 +544,12 @@ public final class Player implements } private void initListeners() { + binding.qualityTextView.setOnClickListener( + new QualityClickListener(this, qualityPopupMenu)); + binding.playbackSpeed.setOnClickListener( + new PlaybackSpeedClickListener(this, playbackSpeedPopupMenu)); + binding.playbackSeekBar.setOnSeekBarChangeListener(this); - binding.playbackSpeed.setOnClickListener(this); - binding.qualityTextView.setOnClickListener(this); binding.captionTextView.setOnClickListener(this); binding.resizeTextView.setOnClickListener(this); binding.playbackLiveSync.setOnClickListener(this); @@ -532,10 +558,15 @@ public final class Player implements gestureDetector = new GestureDetectorCompat(context, playerGestureListener); binding.getRoot().setOnTouchListener(playerGestureListener); - binding.queueButton.setOnClickListener(this); - binding.segmentsButton.setOnClickListener(this); - binding.repeatButton.setOnClickListener(this); - binding.shuffleButton.setOnClickListener(this); + binding.queueButton.setOnClickListener(v -> onQueueClicked()); + binding.segmentsButton.setOnClickListener(v -> onSegmentsClicked()); + binding.repeatButton.setOnClickListener(v -> onRepeatClicked()); + binding.shuffleButton.setOnClickListener(v -> onShuffleClicked()); + binding.addToPlaylistButton.setOnClickListener(v -> { + if (getParentActivity() != null) { + onAddToPlaylistClicked(getParentActivity().getSupportFragmentManager()); + } + }); binding.playPauseButton.setOnClickListener(this); binding.playPreviousButton.setOnClickListener(this); @@ -664,6 +695,7 @@ public final class Player implements //////////////////////////////////////////////////////////////////////////*/ //region Playback initialization via intent + @SuppressWarnings("MethodLength") public void handleIntent(@NonNull final Intent intent) { // fail fast if no play queue was provided final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY); @@ -1623,8 +1655,7 @@ public final class Player implements } public boolean getPlaybackSkipSilence() { - return !exoPlayerIsNull() && simpleExoPlayer.getAudioComponent() != null - && simpleExoPlayer.getAudioComponent().getSkipSilenceEnabled(); + return !exoPlayerIsNull() && simpleExoPlayer.getSkipSilenceEnabled(); } public PlaybackParameters getPlaybackParameters() { @@ -1650,9 +1681,7 @@ public final class Player implements savePlaybackParametersToPrefs(this, roundedSpeed, roundedPitch, skipSilence); simpleExoPlayer.setPlaybackParameters( new PlaybackParameters(roundedSpeed, roundedPitch)); - if (simpleExoPlayer.getAudioComponent() != null) { - simpleExoPlayer.getAudioComponent().setSkipSilenceEnabled(skipSilence); - } + simpleExoPlayer.setSkipSilenceEnabled(skipSilence); } //endregion @@ -1916,7 +1945,7 @@ public final class Player implements }, delay); } - private void showHideShadow(final boolean show, final long duration) { + public void showHideShadow(final boolean show, final long duration) { animate(binding.playbackControlsShadow, show, duration, AnimationType.ALPHA, 0, null); animate(binding.playerTopShadow, show, duration, AnimationType.ALPHA, 0, null); animate(binding.playerBottomShadow, show, duration, AnimationType.ALPHA, 0, null); @@ -1930,11 +1959,12 @@ public final class Player implements final boolean showPrev = playQueue.getIndex() != 0; final boolean showNext = playQueue.getIndex() + 1 != playQueue.getStreams().size(); final boolean showQueue = playQueue.getStreams().size() > 1 && !popupPlayerSelected(); - boolean showSegment = false; - if (currentMetadata != null) { - showSegment = !currentMetadata.getMetadata().getStreamSegments().isEmpty() - && !popupPlayerSelected(); - } + /* only when stream has segments and is not playing in popup player */ + final boolean showSegment = !popupPlayerSelected() + && !getCurrentStreamInfo() + .map(StreamInfo::getStreamSegments) + .map(List::isEmpty) + .orElse(/*no stream info=*/true); binding.playPreviousButton.setVisibility(showPrev ? View.VISIBLE : View.INVISIBLE); binding.playPreviousButton.setAlpha(showPrev ? 1.0f : 0.0f); @@ -1974,9 +2004,29 @@ public final class Player implements // Playback states //////////////////////////////////////////////////////////////////////////*/ //region Playback states + @Override + public void onPlayWhenReadyChanged(final boolean playWhenReady, final int reason) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onPlayWhenReadyChanged() called with: " + + "playWhenReady = [" + playWhenReady + "], " + + "reason = [" + reason + "]"); + } + final int playbackState = exoPlayerIsNull() + ? com.google.android.exoplayer2.Player.STATE_IDLE + : simpleExoPlayer.getPlaybackState(); + updatePlaybackState(playWhenReady, playbackState); + } - @Override // exoplayer listener - public void onPlayerStateChanged(final boolean playWhenReady, final int playbackState) { + @Override + public void onPlaybackStateChanged(final int playbackState) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onPlaybackStateChanged() called with: " + + "playbackState = [" + playbackState + "]"); + } + updatePlaybackState(getPlayWhenReady(), playbackState); + } + + private void updatePlaybackState(final boolean playWhenReady, final int playbackState) { if (DEBUG) { Log.d(TAG, "ExoPlayer - onPlayerStateChanged() called with: " + "playWhenReady = [" + playWhenReady + "], " @@ -1985,7 +2035,7 @@ public final class Player implements if (currentState == STATE_PAUSED_SEEK) { if (DEBUG) { - Log.d(TAG, "ExoPlayer - onPlayerStateChanged() is currently blocked"); + Log.d(TAG, "updatePlaybackState() is currently blocked"); } return; } @@ -2000,8 +2050,6 @@ public final class Player implements } break; case com.google.android.exoplayer2.Player.STATE_READY: //3 - maybeUpdateCurrentMetadata(); - maybeCorrectSeekPosition(); if (!isPrepared) { isPrepared = true; onPrepared(playWhenReady); @@ -2018,18 +2066,11 @@ public final class Player implements @Override // exoplayer listener public void onIsLoadingChanged(final boolean isLoading) { - if (DEBUG) { - Log.d(TAG, "ExoPlayer - onLoadingChanged() called with: " - + "isLoading = [" + isLoading + "]"); - } - if (!isLoading && currentState == STATE_PAUSED && isProgressLoopRunning()) { stopProgressLoop(); } else if (isLoading && !isProgressLoopRunning()) { startProgressLoop(); } - - maybeUpdateCurrentMetadata(); } @Override // own playback listener @@ -2384,6 +2425,32 @@ public final class Player implements + /*////////////////////////////////////////////////////////////////////////// + // Playlist append + //////////////////////////////////////////////////////////////////////////*/ + //region Playlist append + + public void onAddToPlaylistClicked(@NonNull final FragmentManager fragmentManager) { + if (DEBUG) { + Log.d(TAG, "onAddToPlaylistClicked() called"); + } + + if (getPlayQueue() != null) { + PlaylistDialog.createCorrespondingDialog( + getContext(), + getPlayQueue() + .getStreams() + .stream() + .map(StreamEntity::new) + .collect(Collectors.toList()), + dialog -> dialog.show(fragmentManager, TAG) + ); + } + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// // Mute / Unmute //////////////////////////////////////////////////////////////////////////*/ @@ -2415,28 +2482,52 @@ public final class Player implements //////////////////////////////////////////////////////////////////////////*/ //region ExoPlayer listeners (that didn't fit in other categories) + /** + *

Listens for event or state changes on ExoPlayer. When any event happens, we check for + * changes in the currently-playing metadata and update the encapsulating + * {@link Player}. Downstream listeners are also informed.

+ * + *

When the renewed metadata contains any error, it is reported as a notification. + * This is done because not all source resolution errors are {@link PlaybackException}, which + * are also captured by {@link ExoPlayer} and stops the playback.

+ * + * @param player The {@link com.google.android.exoplayer2.Player} whose state changed. + * @param events The {@link com.google.android.exoplayer2.Player.Events} that has triggered + * the player state changes. + **/ @Override - public void onTimelineChanged(@NonNull final Timeline timeline, final int reason) { - if (DEBUG) { - Log.d(TAG, "ExoPlayer - onTimelineChanged() called with " - + "timeline size = [" + timeline.getWindowCount() + "], " - + "reason = [" + reason + "]"); - } - - maybeUpdateCurrentMetadata(); - // force recreate notification to ensure seek bar is shown when preparation finishes - NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, true); + public void onEvents(@NonNull final com.google.android.exoplayer2.Player player, + @NonNull final com.google.android.exoplayer2.Player.Events events) { + Listener.super.onEvents(player, events); + MediaItemTag.from(player.getCurrentMediaItem()).ifPresent(tag -> { + if (tag == currentMetadata) { + return; + } + currentMetadata = tag; + if (!tag.getErrors().isEmpty()) { + final ErrorInfo errorInfo = new ErrorInfo( + tag.getErrors().get(0), + UserAction.PLAY_STREAM, + "Loading failed for [" + tag.getTitle() + "]: " + tag.getStreamUrl(), + tag.getServiceId()); + ErrorUtil.createNotification(context, errorInfo); + } + tag.getMaybeStreamInfo().ifPresent(info -> { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onEvents() update stream info: " + info.getName()); + } + updateMetadataWith(info); + }); + }); } @Override - public void onTracksChanged(@NonNull final TrackGroupArray trackGroups, - @NonNull final TrackSelectionArray trackSelections) { + public void onTracksInfoChanged(@NonNull final TracksInfo tracksInfo) { if (DEBUG) { Log.d(TAG, "ExoPlayer - onTracksChanged(), " - + "track group size = " + trackGroups.length); + + "track group size = " + tracksInfo.getTrackGroupInfos().size()); } - maybeUpdateCurrentMetadata(); - onTextTracksChanged(); + onTextTracksChanged(tracksInfo); } @Override @@ -2449,11 +2540,15 @@ public final class Player implements } @Override - public void onPositionDiscontinuity( - final PositionInfo oldPosition, final PositionInfo newPosition, - @DiscontinuityReason final int discontinuityReason) { + public void onPositionDiscontinuity(@NonNull final PositionInfo oldPosition, + @NonNull final PositionInfo newPosition, + @DiscontinuityReason final int discontinuityReason) { if (DEBUG) { Log.d(TAG, "ExoPlayer - onPositionDiscontinuity() called with " + + "oldPositionIndex = [" + oldPosition.mediaItemIndex + "], " + + "oldPositionMs = [" + oldPosition.positionMs + "], " + + "newPositionIndex = [" + newPosition.mediaItemIndex + "], " + + "newPositionMs = [" + newPosition.positionMs + "], " + "discontinuityReason = [" + discontinuityReason + "]"); } if (playQueue == null) { @@ -2461,13 +2556,13 @@ public final class Player implements } // Refresh the playback if there is a transition to the next video - final int newWindowIndex = simpleExoPlayer.getCurrentWindowIndex(); + final int newIndex = newPosition.mediaItemIndex; switch (discontinuityReason) { case DISCONTINUITY_REASON_AUTO_TRANSITION: case DISCONTINUITY_REASON_REMOVE: // When player is in single repeat mode and a period transition occurs, // we need to register a view count here since no metadata has changed - if (getRepeatMode() == REPEAT_MODE_ONE && newWindowIndex == playQueue.getIndex()) { + if (getRepeatMode() == REPEAT_MODE_ONE && newIndex == playQueue.getIndex()) { registerStreamViewed(); break; } @@ -2480,16 +2575,15 @@ public final class Player implements } case DISCONTINUITY_REASON_SEEK_ADJUSTMENT: case DISCONTINUITY_REASON_INTERNAL: - if (playQueue.getIndex() != newWindowIndex) { + // Player index may be invalid when playback is blocked + if (getCurrentState() != STATE_BLOCKED && newIndex != playQueue.getIndex()) { saveStreamProgressStateCompleted(); // current stream has ended - playQueue.setIndex(newWindowIndex); + playQueue.setIndex(newIndex); } break; case DISCONTINUITY_REASON_SKIP: break; // only makes Android Studio linter happy, as there are no ads } - - maybeUpdateCurrentMetadata(); } @Override @@ -2499,7 +2593,7 @@ public final class Player implements } @Override - public void onCues(final List cues) { + public void onCues(@NonNull final List cues) { binding.subtitleView.onCues(cues); } //endregion @@ -2514,94 +2608,103 @@ public final class Player implements * Process exceptions produced by {@link com.google.android.exoplayer2.ExoPlayer ExoPlayer}. *

There are multiple types of errors:

*
    - *
  • {@link ExoPlaybackException#TYPE_SOURCE TYPE_SOURCE}
  • - *
  • {@link ExoPlaybackException#TYPE_UNEXPECTED TYPE_UNEXPECTED}: - * If a runtime error occurred, then we can try to recover it by restarting the playback - * after setting the timestamp recovery.
  • - *
  • {@link ExoPlaybackException#TYPE_RENDERER TYPE_RENDERER}: - * If the renderer failed, treat the error as unrecoverable.
  • + *
  • {@link PlaybackException#ERROR_CODE_BEHIND_LIVE_WINDOW BEHIND_LIVE_WINDOW}: + * If the playback on livestreams are lagged too far behind the current playable + * window. Then we seek to the latest timestamp and restart the playback. + * This error is catchable. + *
  • + *
  • From {@link PlaybackException#ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE BAD_IO} to + * {@link PlaybackException#ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED UNSUPPORTED_FORMATS}: + * If the stream source is validated by the extractor but not recognized by the player, + * then we can try to recover playback by signalling an error on the {@link PlayQueue}.
  • + *
  • For {@link PlaybackException#ERROR_CODE_TIMEOUT PLAYER_TIMEOUT}, + * {@link PlaybackException#ERROR_CODE_IO_UNSPECIFIED MEDIA_SOURCE_RESOLVER_TIMEOUT} and + * {@link PlaybackException#ERROR_CODE_IO_NETWORK_CONNECTION_FAILED NO_NETWORK}: + * We can keep set the recovery record and keep to player at the current state until + * it is ready to play by restarting the {@link MediaSourceManager}.
  • + *
  • On any ExoPlayer specific issue internal to its device interaction, such as + * {@link PlaybackException#ERROR_CODE_DECODER_INIT_FAILED DECODER_ERROR}: + * We terminate the playback.
  • + *
  • For any other unspecified issue internal: We set a recovery and try to restart + * the playback.
  • + * For any error above that is not explicitly catchable, the player will + * create a notification so users are aware. *
- * - * @see #processSourceError(IOException) - * @see com.google.android.exoplayer2.Player.Listener#onPlayerError(ExoPlaybackException) - */ + * @see com.google.android.exoplayer2.Player.Listener#onPlayerError(PlaybackException) + * */ + // Any error code not explicitly covered here are either unrelated to NewPipe use case + // (e.g. DRM) or not recoverable (e.g. Decoder error). In both cases, the player should + // shutdown. + @SuppressLint("SwitchIntDef") @Override - public void onPlayerError(@NonNull final ExoPlaybackException error) { + public void onPlayerError(@NonNull final PlaybackException error) { Log.e(TAG, "ExoPlayer - onPlayerError() called with:", error); saveStreamProgressState(); boolean isCatchableException = false; - switch (error.type) { - case ExoPlaybackException.TYPE_SOURCE: - isCatchableException = processSourceError(error.getSourceException()); + switch (error.errorCode) { + case ERROR_CODE_BEHIND_LIVE_WINDOW: + isCatchableException = true; + simpleExoPlayer.seekToDefaultPosition(); + simpleExoPlayer.prepare(); + // Inform the user that we are reloading the stream by + // switching to the buffering state + onBuffering(); break; - case ExoPlaybackException.TYPE_UNEXPECTED: + case ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE: + case ERROR_CODE_IO_BAD_HTTP_STATUS: + case ERROR_CODE_IO_FILE_NOT_FOUND: + case ERROR_CODE_IO_NO_PERMISSION: + case ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED: + case ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE: + case ERROR_CODE_PARSING_CONTAINER_MALFORMED: + case ERROR_CODE_PARSING_MANIFEST_MALFORMED: + case ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED: + case ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED: + // Source errors, signal on playQueue and move on: + if (!exoPlayerIsNull() && playQueue != null) { + playQueue.error(); + } + break; + case ERROR_CODE_TIMEOUT: + case ERROR_CODE_IO_UNSPECIFIED: + case ERROR_CODE_IO_NETWORK_CONNECTION_FAILED: + case ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT: + case ERROR_CODE_UNSPECIFIED: + // Reload playback on unexpected errors: setRecovery(); reloadPlayQueueManager(); break; - case ExoPlaybackException.TYPE_REMOTE: - case ExoPlaybackException.TYPE_RENDERER: default: + // API, remote and renderer errors belong here: onPlaybackShutdown(); break; } - if (isCatchableException) { - return; + if (!isCatchableException) { + createErrorNotification(error); } - createErrorNotification(error); - if (fragmentListener != null) { - fragmentListener.onPlayerError(error); + fragmentListener.onPlayerError(error, isCatchableException); } } - private void createErrorNotification(@NonNull final ExoPlaybackException error) { + private void createErrorNotification(@NonNull final PlaybackException error) { final ErrorInfo errorInfo; if (currentMetadata == null) { errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM, - "Player error[type=" + error.type + "] occurred, currentMetadata is null"); + "Player error[type=" + error.getErrorCodeName() + + "] occurred, currentMetadata is null"); } else { errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM, - "Player error[type=" + error.type + "] occurred while playing " - + currentMetadata.getMetadata().getUrl(), - currentMetadata.getMetadata()); + "Player error[type=" + error.getErrorCodeName() + + "] occurred while playing " + currentMetadata.getStreamUrl(), + currentMetadata.getServiceId()); } ErrorUtil.createNotification(context, errorInfo); } - - /** - * Process an {@link IOException} returned by {@link ExoPlaybackException#getSourceException()} - * for {@link ExoPlaybackException#TYPE_SOURCE} exceptions. - * - *

- * This method sets the recovery position and sends an error message to the play queue if the - * exception is not a {@link BehindLiveWindowException}. - *

- * @param error the source error which was thrown by ExoPlayer - * @return whether the exception thrown is a {@link BehindLiveWindowException} ({@code false} - * is always returned if ExoPlayer or the play queue is null) - */ - private boolean processSourceError(final IOException error) { - if (exoPlayerIsNull() || playQueue == null) { - return false; - } - - setRecovery(); - - if (error instanceof BehindLiveWindowException) { - simpleExoPlayer.seekToDefaultPosition(); - simpleExoPlayer.prepare(); - // Inform the user that we are reloading the stream by switching to the buffering state - onBuffering(); - return true; - } - - playQueue.error(); - return false; - } //endregion @@ -2648,7 +2751,7 @@ public final class Player implements } final Timeline currentTimeline = simpleExoPlayer.getCurrentTimeline(); - final int currentWindowIndex = simpleExoPlayer.getCurrentWindowIndex(); + final int currentWindowIndex = simpleExoPlayer.getCurrentMediaItemIndex(); if (currentTimeline.isEmpty() || currentWindowIndex < 0 || currentWindowIndex >= currentTimeline.getWindowCount()) { return false; @@ -2660,20 +2763,19 @@ public final class Player implements } @Override // own playback listener - public void onPlaybackSynchronize(@NonNull final PlayQueueItem item) { + public void onPlaybackSynchronize(@NonNull final PlayQueueItem item, final boolean wasBlocked) { if (DEBUG) { - Log.d(TAG, "Playback - onPlaybackSynchronize() called with " - + "item=[" + item.getTitle() + "], url=[" + item.getUrl() + "]"); + Log.d(TAG, "Playback - onPlaybackSynchronize(was blocked: " + wasBlocked + + ") called with item=[" + item.getTitle() + "], url=[" + item.getUrl() + "]"); } if (exoPlayerIsNull() || playQueue == null) { return; } - final boolean onPlaybackInitial = currentItem == null; final boolean hasPlayQueueItemChanged = currentItem != item; final int currentPlayQueueIndex = playQueue.indexOf(item); - final int currentPlaylistIndex = simpleExoPlayer.getCurrentWindowIndex(); + final int currentPlaylistIndex = simpleExoPlayer.getCurrentMediaItemIndex(); final int currentPlaylistSize = simpleExoPlayer.getCurrentTimeline().getWindowCount(); // If nothing to synchronize @@ -2695,8 +2797,7 @@ public final class Player implements + "index=[" + currentPlayQueueIndex + "] with " + "playlist length=[" + currentPlaylistSize + "]"); - } else if (currentPlaylistIndex != currentPlayQueueIndex || onPlaybackInitial - || !isPlaying()) { + } else if (wasBlocked || currentPlaylistIndex != currentPlayQueueIndex || !isPlaying()) { if (DEBUG) { Log.d(TAG, "Playback - Rewinding to correct " + "index=[" + currentPlayQueueIndex + "], " @@ -2713,28 +2814,6 @@ public final class Player implements } } - private void maybeCorrectSeekPosition() { - if (playQueue == null || exoPlayerIsNull() || currentMetadata == null) { - return; - } - - final PlayQueueItem currentSourceItem = playQueue.getItem(); - if (currentSourceItem == null) { - return; - } - - final StreamInfo currentInfo = currentMetadata.getMetadata(); - final long presetStartPositionMillis = currentInfo.getStartPosition() * 1000; - if (presetStartPositionMillis > 0L) { - // Has another start position? - if (DEBUG) { - Log.d(TAG, "Playback - Seeking to preset start " - + "position=[" + presetStartPositionMillis + "]"); - } - seekTo(presetStartPositionMillis); - } - } - public void seekTo(final long positionMillis) { if (DEBUG) { Log.d(TAG, "seekBy() called with: position = [" + positionMillis + "]"); @@ -2896,24 +2975,22 @@ public final class Player implements //region StreamInfo history: views and progress private void registerStreamViewed() { - if (currentMetadata != null) { - databaseUpdateDisposable.add(recordManager.onViewed(currentMetadata.getMetadata()) - .onErrorComplete().subscribe()); - } + getCurrentStreamInfo().ifPresent(info -> databaseUpdateDisposable + .add(recordManager.onViewed(info).onErrorComplete().subscribe())); } private void saveStreamProgressState(final long progressMillis) { - if (currentMetadata == null + if (!getCurrentStreamInfo().isPresent() || !prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) { return; } if (DEBUG) { Log.d(TAG, "saveStreamProgressState() called with: progressMillis=" + progressMillis - + ", currentMetadata=[" + currentMetadata.getMetadata().getName() + "]"); + + ", currentMetadata=[" + getCurrentStreamInfo().get().getName() + "]"); } databaseUpdateDisposable - .add(recordManager.saveStreamState(currentMetadata.getMetadata(), progressMillis) + .add(recordManager.saveStreamState(getCurrentStreamInfo().get(), progressMillis) .observeOn(AndroidSchedulers.mainThread()) .doOnError(e -> { if (DEBUG) { @@ -2926,7 +3003,7 @@ public final class Player implements public void saveStreamProgressState() { if (exoPlayerIsNull() || currentMetadata == null || playQueue == null - || playQueue.getIndex() != simpleExoPlayer.getCurrentWindowIndex()) { + || playQueue.getIndex() != simpleExoPlayer.getCurrentMediaItemIndex()) { // Make sure play queue and current window index are equal, to prevent saving state for // the wrong stream on discontinuity (e.g. when the stream just changed but the // playQueue index and currentMetadata still haven't updated) @@ -2939,10 +3016,9 @@ public final class Player implements } public void saveStreamProgressStateCompleted() { - if (currentMetadata != null) { - // current stream has ended, so the progress is its duration (+1 to overcome rounding) - saveStreamProgressState((currentMetadata.getMetadata().getDuration() + 1) * 1000); - } + // current stream has ended, so the progress is its duration (+1 to overcome rounding) + getCurrentStreamInfo().ifPresent(info -> + saveStreamProgressState((info.getDuration() + 1) * 1000)); } //endregion @@ -2953,8 +3029,7 @@ public final class Player implements //////////////////////////////////////////////////////////////////////////*/ //region Metadata - private void onMetadataChanged(@NonNull final MediaSourceTag tag) { - final StreamInfo info = tag.getMetadata(); + private void onMetadataChanged(@NonNull final StreamInfo info) { if (DEBUG) { Log.d(TAG, "Playback - onMetadataChanged() called, playing: " + info.getName()); } @@ -2964,12 +3039,10 @@ public final class Player implements updateStreamRelatedViews(); showHideKodiButton(); - binding.titleTextView.setText(tag.getMetadata().getName()); - binding.channelTextView.setText(tag.getMetadata().getUploaderName()); + binding.titleTextView.setText(info.getName()); + binding.channelTextView.setText(info.getUploaderName()); - this.seekbarPreviewThumbnailHolder.resetFrom( - this.getContext(), - tag.getMetadata().getPreviewFrames()); + this.seekbarPreviewThumbnailHolder.resetFrom(this.getContext(), info.getPreviewFrames()); NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); @@ -2979,9 +3052,7 @@ public final class Player implements getVideoTitle(), getUploaderName(), showThumbnail ? Optional.ofNullable(getThumbnail()) : Optional.empty(), - StreamTypeUtil.isLiveStream(tag.getMetadata().getStreamType()) - ? -1 - : tag.getMetadata().getDuration() + StreamTypeUtil.isLiveStream(info.getStreamType()) ? -1 : info.getDuration() ); notifyMetadataUpdateToListeners(); @@ -2998,39 +3069,21 @@ public final class Player implements } } - private void maybeUpdateCurrentMetadata() { + private void updateMetadataWith(@NonNull final StreamInfo streamInfo) { if (exoPlayerIsNull()) { return; } - final MediaSourceTag metadata; - try { - metadata = (MediaSourceTag) simpleExoPlayer.getCurrentTag(); - } catch (IndexOutOfBoundsException | ClassCastException error) { - if (DEBUG) { - Log.d(TAG, "Could not update metadata: " + error.getMessage()); - error.printStackTrace(); - } - return; - } - - if (metadata == null) { - return; - } - maybeAutoQueueNextStream(metadata); - - if (currentMetadata == metadata) { - return; - } - currentMetadata = metadata; - onMetadataChanged(metadata); + maybeAutoQueueNextStream(streamInfo); + onMetadataChanged(streamInfo); + NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, true); } @NonNull private String getVideoUrl() { return currentMetadata == null ? context.getString(R.string.unknown_content) - : currentMetadata.getMetadata().getUrl(); + : currentMetadata.getStreamUrl(); } @NonNull @@ -3038,7 +3091,7 @@ public final class Player implements final int timeSeconds = binding.playbackSeekBar.getProgress() / 1000; String videoUrl = getVideoUrl(); if (!isLive() && timeSeconds >= 0 && currentMetadata != null - && currentMetadata.getMetadata().getServiceId() == YouTube.getServiceId()) { + && currentMetadata.getServiceId() == YouTube.getServiceId()) { // Timestamp doesn't make sense in a live stream so drop it videoUrl += ("&t=" + timeSeconds); } @@ -3049,14 +3102,14 @@ public final class Player implements public String getVideoTitle() { return currentMetadata == null ? context.getString(R.string.unknown_content) - : currentMetadata.getMetadata().getName(); + : currentMetadata.getTitle(); } @NonNull public String getUploaderName() { return currentMetadata == null ? context.getString(R.string.unknown_content) - : currentMetadata.getMetadata().getUploaderName(); + : currentMetadata.getUploaderName(); } @Nullable @@ -3076,14 +3129,14 @@ public final class Player implements //////////////////////////////////////////////////////////////////////////*/ //region Play queue, segments and streams - private void maybeAutoQueueNextStream(@NonNull final MediaSourceTag metadata) { + private void maybeAutoQueueNextStream(@NonNull final StreamInfo info) { if (playQueue == null || playQueue.getIndex() != playQueue.size() - 1 || getRepeatMode() != REPEAT_MODE_OFF || !PlayerHelper.isAutoQueueEnabled(context)) { return; } // auto queue when starting playback on the last item when not repeating - final PlayQueue autoQueue = PlayerHelper.autoQueueOf(metadata.getMetadata(), + final PlayQueue autoQueue = PlayerHelper.autoQueueOf(info, playQueue.getStreams()); if (autoQueue != null) { playQueue.append(autoQueue.getStreams()); @@ -3100,7 +3153,7 @@ public final class Player implements return; } - if (playQueue.getIndex() == index && simpleExoPlayer.getCurrentWindowIndex() == index) { + if (playQueue.getIndex() == index && simpleExoPlayer.getCurrentMediaItemIndex() == index) { seekToDefault(); } else { saveStreamProgressState(); @@ -3125,6 +3178,7 @@ public final class Player implements binding.itemsListHeaderDuration.setVisibility(View.VISIBLE); binding.shuffleButton.setVisibility(View.VISIBLE); binding.repeatButton.setVisibility(View.VISIBLE); + binding.addToPlaylistButton.setVisibility(View.VISIBLE); hideControls(0, 0); binding.itemsListPanel.requestFocus(); @@ -3162,6 +3216,7 @@ public final class Player implements binding.itemsListHeaderDuration.setVisibility(View.GONE); binding.shuffleButton.setVisibility(View.GONE); binding.repeatButton.setVisibility(View.GONE); + binding.addToPlaylistButton.setVisibility(View.GONE); hideControls(0, 0); binding.itemsListPanel.requestFocus(); @@ -3184,12 +3239,11 @@ public final class Player implements itemTouchHelper.attachToRecyclerView(null); } - if (currentMetadata != null) { - segmentAdapter.setItems(currentMetadata.getMetadata()); - } + getCurrentStreamInfo().ifPresent(segmentAdapter::setItems); binding.shuffleButton.setVisibility(View.GONE); binding.repeatButton.setVisibility(View.GONE); + binding.addToPlaylistButton.setVisibility(View.GONE); binding.itemsListClose.setOnClickListener(view -> closeItemsList()); } @@ -3209,6 +3263,9 @@ public final class Player implements binding.itemsListPanel.setTranslationY( -binding.itemsListPanel.getHeight() * 5); }); + + // clear focus, otherwise a white rectangle remains on top of the player + binding.itemsListClose.clearFocus(); binding.playPauseButton.requestFocus(); } } @@ -3236,7 +3293,9 @@ public final class Player implements private int getNearestStreamSegmentPosition(final long playbackPosition) { int nearestPosition = 0; - final List segments = currentMetadata.getMetadata().getStreamSegments(); + final List segments = getCurrentStreamInfo() + .map(StreamInfo::getStreamSegments) + .orElse(Collections.emptyList()); for (int i = 0; i < segments.size(); i++) { if (segments.get(i).getStartTimeSeconds() * 1000L > playbackPosition) { @@ -3292,7 +3351,27 @@ public final class Player implements @Override // own playback listener @Nullable public MediaSource sourceOf(final PlayQueueItem item, final StreamInfo info) { - return (isAudioOnly ? audioResolver : videoResolver).resolve(info); + if (audioPlayerSelected()) { + return audioResolver.resolve(info); + } + + if (isAudioOnly && videoResolver.getStreamSourceType().orElse( + SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY) + == SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY) { + // If the current info has only video streams with audio and if the stream is played as + // audio, we need to use the audio resolver, otherwise the video stream will be played + // in background. + return audioResolver.resolve(info); + } + + // Even if the stream is played in background, we need to use the video resolver if the + // info played is separated video-only and audio-only streams; otherwise, if the audio + // resolver was called when the app was in background, the app will only stream audio when + // the user come back to the app and will never fetch the video stream. + // Note that the video is not fetched when the app is in background because the video + // renderer is fully disabled (see useVideoSource method), except for HLS streams + // (see https://github.com/google/ExoPlayer/issues/9282). + return videoResolver.resolve(info); } public void disablePreloadingOfCurrentTrack() { @@ -3307,10 +3386,10 @@ public final class Player implements } private void updateStreamRelatedViews() { - if (currentMetadata == null) { + if (!getCurrentStreamInfo().isPresent()) { return; } - final StreamInfo info = currentMetadata.getMetadata(); + final StreamInfo info = getCurrentStreamInfo().get(); binding.qualityTextView.setVisibility(View.GONE); binding.playbackSpeed.setVisibility(View.GONE); @@ -3338,12 +3417,16 @@ public final class Player implements break; case VIDEO_STREAM: - if (info.getVideoStreams().size() + info.getVideoOnlyStreams().size() == 0) { + if (currentMetadata == null + || !currentMetadata.getMaybeQuality().isPresent() + || (info.getVideoStreams().isEmpty() + && info.getVideoOnlyStreams().isEmpty())) { break; } - availableStreams = currentMetadata.getSortedAvailableVideoStreams(); - selectedStreamIndex = currentMetadata.getSelectedVideoStreamIndex(); + availableStreams = currentMetadata.getMaybeQuality().get().getSortedVideoStreams(); + selectedStreamIndex = + currentMetadata.getMaybeQuality().get().getSelectedVideoStreamIndex(); buildQualityMenu(); binding.qualityTextView.setVisibility(View.VISIBLE); @@ -3430,17 +3513,7 @@ public final class Player implements return; } captionPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_CAPTION); - - final String userPreferredLanguage = - prefs.getString(context.getString(R.string.caption_user_set_key), null); - /* - * only search for autogenerated cc as fallback - * if "(auto-generated)" was not already selected - * we are only looking for "(" instead of "(auto-generated)" to hopefully get all - * internationalized variants such as "(automatisch-erzeugt)" and so on - */ - boolean searchForAutogenerated = userPreferredLanguage != null - && !userPreferredLanguage.contains("("); + captionPopupMenu.setOnDismissListener(this); // Add option for turning off caption final MenuItem captionOffItem = captionPopupMenu.getMenu().add(POPUP_MENU_ID_CAPTION, @@ -3463,30 +3536,54 @@ public final class Player implements captionItem.setOnMenuItemClickListener(menuItem -> { final int textRendererIndex = getCaptionRendererIndex(); if (textRendererIndex != RENDERER_UNAVAILABLE) { - trackSelector.setPreferredTextLanguage(captionLanguage); + // DefaultTrackSelector will select for text tracks in the following order. + // When multiple tracks share the same rank, a random track will be chosen. + // 1. ANY track exactly matching preferred language name + // 2. ANY track exactly matching preferred language stem + // 3. ROLE_FLAG_CAPTION track matching preferred language stem + // 4. ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND track matching preferred language stem + // This means if a caption track of preferred language is not available, + // then an auto-generated track of that language will be chosen automatically. trackSelector.setParameters(trackSelector.buildUponParameters() + .setPreferredTextLanguages(captionLanguage, + PlayerHelper.captionLanguageStemOf(captionLanguage)) + .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) .setRendererDisabled(textRendererIndex, false)); prefs.edit().putString(context.getString(R.string.caption_user_set_key), captionLanguage).apply(); } return true; }); - // apply caption language from previous user preference - if (userPreferredLanguage != null - && (captionLanguage.equals(userPreferredLanguage) - || (searchForAutogenerated && captionLanguage.startsWith(userPreferredLanguage)) - || (userPreferredLanguage.contains("(") && captionLanguage.startsWith( - userPreferredLanguage.substring(0, userPreferredLanguage.indexOf('(')))))) { - final int textRendererIndex = getCaptionRendererIndex(); - if (textRendererIndex != RENDERER_UNAVAILABLE) { - trackSelector.setPreferredTextLanguage(captionLanguage); - trackSelector.setParameters(trackSelector.buildUponParameters() - .setRendererDisabled(textRendererIndex, false)); - } - searchForAutogenerated = false; - } } - captionPopupMenu.setOnDismissListener(this); + + // apply caption language from previous user preference + final int textRendererIndex = getCaptionRendererIndex(); + if (textRendererIndex == RENDERER_UNAVAILABLE) { + return; + } + + // If user prefers to show no caption, then disable the renderer. + // Otherwise, DefaultTrackSelector may automatically find an available caption + // and display that. + final String userPreferredLanguage = + prefs.getString(context.getString(R.string.caption_user_set_key), null); + if (userPreferredLanguage == null) { + trackSelector.setParameters(trackSelector.buildUponParameters() + .setRendererDisabled(textRendererIndex, true)); + return; + } + + // Only set preferred language if it does not match the user preference, + // otherwise there might be an infinite cycle at onTextTracksChanged. + final List selectedPreferredLanguages = + trackSelector.getParameters().preferredTextLanguages; + if (!selectedPreferredLanguages.contains(userPreferredLanguage)) { + trackSelector.setParameters(trackSelector.buildUponParameters() + .setPreferredTextLanguages(userPreferredLanguage, + PlayerHelper.captionLanguageStemOf(userPreferredLanguage)) + .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) + .setRendererDisabled(textRendererIndex, false)); + } } /** @@ -3544,37 +3641,6 @@ public final class Player implements } } - private void onQualitySelectorClicked() { - if (DEBUG) { - Log.d(TAG, "onQualitySelectorClicked() called"); - } - qualityPopupMenu.show(); - isSomePopupMenuVisible = true; - - final VideoStream videoStream = getSelectedVideoStream(); - if (videoStream != null) { - final String qualityText = MediaFormat.getNameById(videoStream.getFormatId()) + " " - + videoStream.resolution; - binding.qualityTextView.setText(qualityText); - } - - saveWasPlaying(); - } - - private void onPlaybackSpeedClicked() { - if (DEBUG) { - Log.d(TAG, "onPlaybackSpeedClicked() called"); - } - if (videoPlayerSelected()) { - PlaybackParameterDialog.newInstance(getPlaybackSpeed(), getPlaybackPitch(), - getPlaybackSkipSilence(), this::setPlaybackParameters) - .show(getParentActivity().getSupportFragmentManager(), null); - } else { - playbackSpeedPopupMenu.show(); - isSomePopupMenuVisible = true; - } - } - private void onCaptionClicked() { if (DEBUG) { Log.d(TAG, "onCaptionClicked() called"); @@ -3613,40 +3679,43 @@ public final class Player implements binding.subtitleView.setStyle(captionStyle); } - private void onTextTracksChanged() { - final int textRenderer = getCaptionRendererIndex(); - + private void onTextTracksChanged(@NonNull final TracksInfo currentTrackInfo) { if (binding == null) { return; } + if (trackSelector.getCurrentMappedTrackInfo() == null - || textRenderer == RENDERER_UNAVAILABLE) { + || !currentTrackInfo.isTypeSupportedOrEmpty(C.TRACK_TYPE_TEXT)) { binding.captionTextView.setVisibility(View.GONE); return; } - final TrackGroupArray textTracks = trackSelector.getCurrentMappedTrackInfo() - .getTrackGroups(textRenderer); - // Extract all loaded languages - final List availableLanguages = new ArrayList<>(textTracks.length); - for (int i = 0; i < textTracks.length; i++) { - final TrackGroup textTrack = textTracks.get(i); - if (textTrack.length > 0) { - availableLanguages.add(textTrack.getFormat(0).language); - } - } + final List textTracks = currentTrackInfo + .getTrackGroupInfos() + .stream() + .filter(trackGroupInfo -> C.TRACK_TYPE_TEXT == trackGroupInfo.getTrackType()) + .collect(Collectors.toList()); + final List availableLanguages = textTracks.stream() + .map(TracksInfo.TrackGroupInfo::getTrackGroup) + .filter(textTrack -> textTrack.length > 0) + .map(textTrack -> textTrack.getFormat(0).language) + .collect(Collectors.toList()); + + // Find selected text track + final Optional selectedTracks = textTracks.stream() + .filter(TracksInfo.TrackGroupInfo::isSelected) + .filter(info -> info.getTrackGroup().length >= 1) + .map(info -> info.getTrackGroup().getFormat(0)) + .findFirst(); - // Normalize mismatching language strings - final String preferredLanguage = trackSelector.getPreferredTextLanguage(); // Build UI buildCaptionMenu(availableLanguages); - if (trackSelector.getParameters().getRendererDisabled(textRenderer) - || preferredLanguage == null || (!availableLanguages.contains(preferredLanguage) - && !containsCaseInsensitive(availableLanguages, preferredLanguage))) { + if (trackSelector.getParameters().getRendererDisabled(getCaptionRendererIndex()) + || !selectedTracks.isPresent()) { binding.captionTextView.setText(R.string.caption_none); } else { - binding.captionTextView.setText(preferredLanguage); + binding.captionTextView.setText(selectedTracks.get().language); } binding.captionTextView.setVisibility( availableLanguages.isEmpty() ? View.GONE : View.VISIBLE); @@ -3679,11 +3748,7 @@ public final class Player implements if (DEBUG) { Log.d(TAG, "onClick() called with: v = [" + v + "]"); } - if (v.getId() == binding.qualityTextView.getId()) { - onQualitySelectorClicked(); - } else if (v.getId() == binding.playbackSpeed.getId()) { - onPlaybackSpeedClicked(); - } else if (v.getId() == binding.resizeTextView.getId()) { + if (v.getId() == binding.resizeTextView.getId()) { onResizeClicked(); } else if (v.getId() == binding.captionTextView.getId()) { onCaptionClicked(); @@ -3695,18 +3760,6 @@ public final class Player implements playPrevious(); } else if (v.getId() == binding.playNextButton.getId()) { playNext(); - } else if (v.getId() == binding.queueButton.getId()) { - onQueueClicked(); - return; - } else if (v.getId() == binding.segmentsButton.getId()) { - onSegmentsClicked(); - return; - } else if (v.getId() == binding.repeatButton.getId()) { - onRepeatClicked(); - return; - } else if (v.getId() == binding.shuffleButton.getId()) { - onShuffleClicked(); - return; } else if (v.getId() == binding.moreOptionsButton.getId()) { onMoreOptionsClicked(); } else if (v.getId() == binding.share.getId()) { @@ -3735,23 +3788,33 @@ public final class Player implements context.sendBroadcast(new Intent(VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER)); } - if (currentState != STATE_COMPLETED) { - controlsVisibilityHandler.removeCallbacksAndMessages(null); - showHideShadow(true, DEFAULT_CONTROLS_DURATION); - animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION, - AnimationType.ALPHA, 0, () -> { - if (currentState == STATE_PLAYING && !isSomePopupMenuVisible) { - if (v.getId() == binding.playPauseButton.getId() - // Hide controls in fullscreen immediately - || (v.getId() == binding.screenRotationButton.getId() - && isFullscreen)) { - hideControls(0, 0); - } else { - hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); - } - } - }); + manageControlsAfterOnClick(v); + } + + /** + * Manages the controls after a click occurred on the player UI. + * @param v – The view that was clicked + */ + public void manageControlsAfterOnClick(@NonNull final View v) { + if (currentState == STATE_COMPLETED) { + return; } + + controlsVisibilityHandler.removeCallbacksAndMessages(null); + showHideShadow(true, DEFAULT_CONTROLS_DURATION); + animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION, + AnimationType.ALPHA, 0, () -> { + if (currentState == STATE_PLAYING && !isSomePopupMenuVisible) { + if (v.getId() == binding.playPauseButton.getId() + // Hide controls in fullscreen immediately + || (v.getId() == binding.screenRotationButton.getId() + && isFullscreen)) { + hideControls(0, 0); + } else { + hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); + } + } + }); } @Override @@ -3773,6 +3836,10 @@ public final class Player implements case KeyEvent.KEYCODE_SPACE: if (isFullscreen) { playPause(); + if (isPlaying()) { + hideControls(0, 0); + } + return true; } break; case KeyEvent.KEYCODE_BACK: @@ -3786,8 +3853,9 @@ public final class Player implements case KeyEvent.KEYCODE_DPAD_DOWN: case KeyEvent.KEYCODE_DPAD_RIGHT: case KeyEvent.KEYCODE_DPAD_CENTER: - if (binding.getRoot().hasFocus() && !binding.playbackControlRoot.hasFocus()) { - // do not interfere with focus in playlist etc. + if ((binding.getRoot().hasFocus() && !binding.playbackControlRoot.hasFocus()) + || isQueueVisible) { + // do not interfere with focus in playlist and play queue etc. return false; } @@ -3795,15 +3863,13 @@ public final class Player implements return true; } - if (!isControlsVisible()) { - if (!isQueueVisible) { - binding.playPauseButton.requestFocus(); - } + if (isControlsVisible()) { + hideControls(DEFAULT_CONTROLS_DURATION, DPAD_CONTROLS_HIDE_TIME); + } else { + binding.playPauseButton.requestFocus(); showControlsThenHide(); showSystemUIPartially(); return true; - } else { - hideControls(DEFAULT_CONTROLS_DURATION, DPAD_CONTROLS_HIDE_TIME); } break; } @@ -3848,10 +3914,10 @@ public final class Player implements } private void onOpenInBrowserClicked() { - if (currentMetadata != null) { - ShareUtils.openUrlInBrowser(getParentActivity(), - currentMetadata.getMetadata().getOriginalUrl()); - } + getCurrentStreamInfo() + .map(Info::getOriginalUrl) + .ifPresent(originalUrl -> ShareUtils.openUrlInBrowser( + Objects.requireNonNull(getParentActivity()), originalUrl)); } //endregion @@ -4107,12 +4173,14 @@ public final class Player implements } private void notifyMetadataUpdateToListeners() { - if (fragmentListener != null && currentMetadata != null) { - fragmentListener.onMetadataUpdate(currentMetadata.getMetadata(), playQueue); - } - if (activityListener != null && currentMetadata != null) { - activityListener.onMetadataUpdate(currentMetadata.getMetadata(), playQueue); - } + getCurrentStreamInfo().ifPresent(info -> { + if (fragmentListener != null) { + fragmentListener.onMetadataUpdate(info, playQueue); + } + if (activityListener != null) { + activityListener.onMetadataUpdate(info, playQueue); + } + }); } private void notifyPlaybackUpdateToListeners() { @@ -4147,19 +4215,128 @@ public final class Player implements return (AppCompatActivity) ((ViewGroup) binding.getRoot().getParent()).getContext(); } - private void useVideoSource(final boolean video) { - if (playQueue == null || isAudioOnly == !video || audioPlayerSelected()) { + private void useVideoSource(final boolean videoEnabled) { + if (playQueue == null || isAudioOnly == !videoEnabled || audioPlayerSelected()) { return; } - isAudioOnly = !video; - // When a user returns from background controls could be hidden - // but systemUI will be shown 100%. Hide it + isAudioOnly = !videoEnabled; + // When a user returns from background, controls could be hidden but SystemUI will be shown + // 100%. Hide it. if (!isAudioOnly && !isControlsVisible()) { hideSystemUIIfNeeded(); } + + // The current metadata may be null sometimes (for e.g. when using an unstable connection + // in livestreams) so we will be not able to execute the block below. + // Reload the play queue manager in this case, which is the behavior when we don't know the + // index of the video renderer or playQueueManagerReloadingNeeded returns true. + final Optional optCurrentStreamInfo = getCurrentStreamInfo(); + if (!optCurrentStreamInfo.isPresent()) { + reloadPlayQueueManager(); + setRecovery(); + return; + } + + final StreamInfo info = optCurrentStreamInfo.get(); + + // In the case we don't know the source type, fallback to the one with video with audio or + // audio-only source. + final SourceType sourceType = videoResolver.getStreamSourceType().orElse( + SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY); + + if (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) { + reloadPlayQueueManager(); + } else { + final StreamType streamType = info.getStreamType(); + if (streamType == StreamType.AUDIO_STREAM + || streamType == StreamType.AUDIO_LIVE_STREAM) { + // Nothing to do more than setting the recovery position + setRecovery(); + return; + } + + final DefaultTrackSelector.ParametersBuilder parametersBuilder = + trackSelector.buildUponParameters(); + + if (videoEnabled) { + // Enable again the video track and the subtitles, if there is one selected + parametersBuilder.setDisabledTrackTypes(Collections.emptySet()); + } else { + // Disable the video track and the ability to select subtitles + // Use an ArraySet because we can't use Set.of() on all supported APIs by the app + final ArraySet disabledTracks = new ArraySet<>(); + disabledTracks.add(C.TRACK_TYPE_TEXT); + disabledTracks.add(C.TRACK_TYPE_VIDEO); + parametersBuilder.setDisabledTrackTypes(disabledTracks); + } + + trackSelector.setParameters(parametersBuilder); + } + setRecovery(); - reloadPlayQueueManager(); + } + + /** + * Return whether the play queue manager needs to be reloaded when switching player type. + * + *

+ * The play queue manager needs to be reloaded if the video renderer index is not known and if + * the content is not an audio content, but also if none of the following cases is met: + * + *

    + *
  • the content is an {@link StreamType#AUDIO_STREAM audio stream} or an + * {@link StreamType#AUDIO_LIVE_STREAM audio live stream};
  • + *
  • the content is a {@link StreamType#LIVE_STREAM live stream} and the source type is a + * {@link SourceType#LIVE_STREAM live source};
  • + *
  • the content's source is {@link SourceType#VIDEO_WITH_SEPARATED_AUDIO a video stream + * with a separated audio source} or has no audio-only streams available and is a + * {@link StreamType#LIVE_STREAM live stream} or a + * {@link StreamType#LIVE_STREAM live stream}. + *
  • + *
+ *

+ * + * @param sourceType the {@link SourceType} of the stream + * @param streamInfo the {@link StreamInfo} of the stream + * @param videoRendererIndex the video renderer index of the video source, if that's a video + * source (or {@link #RENDERER_UNAVAILABLE}) + * @return whether the play queue manager needs to be reloaded + */ + private boolean playQueueManagerReloadingNeeded(final SourceType sourceType, + @NonNull final StreamInfo streamInfo, + final int videoRendererIndex) { + final StreamType streamType = streamInfo.getStreamType(); + + if (videoRendererIndex == RENDERER_UNAVAILABLE && streamType != StreamType.AUDIO_STREAM + && streamType != StreamType.AUDIO_LIVE_STREAM) { + return true; + } + + // The content is an audio stream, an audio live stream, or a live stream with a live + // source: it's not needed to reload the play queue manager because the stream source will + // be the same + if ((streamType == StreamType.AUDIO_STREAM || streamType == StreamType.AUDIO_LIVE_STREAM) + || (streamType == StreamType.LIVE_STREAM + && sourceType == SourceType.LIVE_STREAM)) { + return false; + } + + // The content's source is a video with separated audio or a video with audio -> the video + // and its fetch may be disabled + // The content's source is a video with embedded audio and the content has no separated + // audio stream available: it's probably not needed to reload the play queue manager + // because the stream source will be probably the same as the current played + if (sourceType == SourceType.VIDEO_WITH_SEPARATED_AUDIO + || (sourceType == SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY + && isNullOrEmpty(streamInfo.getAudioStreams()))) { + // It's not needed to reload the play queue manager only if the content's stream type + // is a video stream or a live stream + return streamType != StreamType.VIDEO_STREAM && streamType != StreamType.LIVE_STREAM; + } + + // Other cases: the play queue manager reload is needed + return true; } //endregion @@ -4169,6 +4346,10 @@ public final class Player implements //////////////////////////////////////////////////////////////////////////*/ //region Getters + private Optional getCurrentStreamInfo() { + return Optional.ofNullable(currentMetadata).flatMap(MediaItemTag::getMaybeStreamInfo); + } + public int getCurrentState() { return currentState; } @@ -4178,8 +4359,7 @@ public final class Player implements } public boolean isStopped() { - return exoPlayerIsNull() - || simpleExoPlayer.getPlaybackState() == SimpleExoPlayer.STATE_IDLE; + return exoPlayerIsNull() || simpleExoPlayer.getPlaybackState() == ExoPlayer.STATE_IDLE; } public boolean isPlaying() { @@ -4196,8 +4376,8 @@ public final class Player implements private boolean isLive() { try { - return !exoPlayerIsNull() && simpleExoPlayer.isCurrentWindowDynamic(); - } catch (@NonNull final IndexOutOfBoundsException e) { + return !exoPlayerIsNull() && simpleExoPlayer.isCurrentMediaItemDynamic(); + } catch (final IndexOutOfBoundsException e) { // Why would this even happen =(... but lets log it anyway, better safe than sorry if (DEBUG) { Log.d(TAG, "player.isCurrentWindowDynamic() failed: ", e); @@ -4269,6 +4449,10 @@ public final class Player implements return isSomePopupMenuVisible; } + public void setSomePopupMenuVisible(final boolean somePopupMenuVisible) { + isSomePopupMenuVisible = somePopupMenuVisible; + } + public ImageButton getPlayPauseButton() { return binding.playPauseButton; } @@ -4350,6 +4534,11 @@ public final class Player implements public PlayQueueAdapter getPlayQueueAdapter() { return playQueueAdapter; } + + public PlayerBinding getBinding() { + return binding; + } + //endregion @@ -4366,24 +4555,55 @@ public final class Player implements surfaceHolderCallback = new SurfaceHolderCallback(context, simpleExoPlayer); binding.surfaceView.getHolder().addCallback(surfaceHolderCallback); final Surface surface = binding.surfaceView.getHolder().getSurface(); - // initially set the surface manually otherwise - // onRenderedFirstFrame() will not be called - simpleExoPlayer.setVideoSurface(surface); + // ensure player is using an unreleased surface, which the surfaceView might not be + // when starting playback on background or during player switching + if (surface.isValid()) { + // initially set the surface manually otherwise + // onRenderedFirstFrame() will not be called + simpleExoPlayer.setVideoSurface(surface); + } } else { simpleExoPlayer.setVideoSurfaceView(binding.surfaceView); } } private void cleanupVideoSurface() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // >=API23 - if (surfaceHolderCallback != null) { - if (binding != null) { - binding.surfaceView.getHolder().removeCallback(surfaceHolderCallback); - } - surfaceHolderCallback.release(); - surfaceHolderCallback = null; + // Only for API >= 23 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && surfaceHolderCallback != null) { + if (binding != null) { + binding.surfaceView.getHolder().removeCallback(surfaceHolderCallback); } + surfaceHolderCallback.release(); + surfaceHolderCallback = null; } } //endregion + + /** + * Get the video renderer index of the current playing stream. + * + * This method returns the video renderer index of the current + * {@link MappingTrackSelector.MappedTrackInfo} or {@link #RENDERER_UNAVAILABLE} if the current + * {@link MappingTrackSelector.MappedTrackInfo} is null or if there is no video renderer index. + * + * @return the video renderer index or {@link #RENDERER_UNAVAILABLE} if it cannot be get + */ + private int getVideoRendererIndex() { + final MappingTrackSelector.MappedTrackInfo mappedTrackInfo = trackSelector + .getCurrentMappedTrackInfo(); + + if (mappedTrackInfo == null) { + return RENDERER_UNAVAILABLE; + } + + // Check every renderer + return IntStream.range(0, mappedTrackInfo.getRendererCount()) + // Check the renderer is a video renderer and has at least one track + .filter(i -> !mappedTrackInfo.getTrackGroups(i).isEmpty() + && simpleExoPlayer.getRendererType(i) == C.TRACK_TYPE_VIDEO) + // Return the first index found (there is at most one renderer per renderer type) + .findFirst() + // No video renderer index with at least one track found: return unavailable index + .orElse(RENDERER_UNAVAILABLE); + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java index f8d03087e..359eab8b2 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java +++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java @@ -1,6 +1,6 @@ package org.schabi.newpipe.player.event; -import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.PlaybackException; public interface PlayerServiceEventListener extends PlayerEventListener { void onFullscreenStateChanged(boolean fullscreen); @@ -9,7 +9,7 @@ public interface PlayerServiceEventListener extends PlayerEventListener { void onMoreOptionsLongClicked(); - void onPlayerError(ExoPlaybackException error); + void onPlayerError(PlaybackException error, boolean isCatchableException); void hideSystemUiIfNeeded(); } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java index b36f9f234..a05990816 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java @@ -14,7 +14,7 @@ import androidx.core.content.ContextCompat; import androidx.media.AudioFocusRequestCompat; import androidx.media.AudioManagerCompat; -import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.analytics.AnalyticsListener; public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, AnalyticsListener { @@ -27,14 +27,14 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, An private static final int FOCUS_GAIN_TYPE = AudioManagerCompat.AUDIOFOCUS_GAIN; private static final int STREAM_TYPE = AudioManager.STREAM_MUSIC; - private final SimpleExoPlayer player; + private final ExoPlayer player; private final Context context; private final AudioManager audioManager; private final AudioFocusRequestCompat request; public AudioReactor(@NonNull final Context context, - @NonNull final SimpleExoPlayer player) { + @NonNull final ExoPlayer player) { this.player = player; this.context = context; this.audioManager = ContextCompat.getSystemService(context, AudioManager.class); @@ -149,7 +149,8 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, An //////////////////////////////////////////////////////////////////////////*/ @Override - public void onAudioSessionIdChanged(final EventTime eventTime, final int audioSessionId) { + public void onAudioSessionIdChanged(@NonNull final EventTime eventTime, + final int audioSessionId) { notifyAudioSessionUpdate(true, audioSessionId); } private void notifyAudioSessionUpdate(final boolean active, final int audioSessionId) { diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java index 9703a3588..98e04d466 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java @@ -3,12 +3,10 @@ package org.schabi.newpipe.player.helper; import android.content.Context; import android.util.Log; -import androidx.annotation.NonNull; - -import com.google.android.exoplayer2.database.ExoDatabaseProvider; +import com.google.android.exoplayer2.database.StandaloneDatabaseProvider; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSource; -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; import com.google.android.exoplayer2.upstream.FileDataSource; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.upstream.cache.CacheDataSink; @@ -18,6 +16,8 @@ import com.google.android.exoplayer2.upstream.cache.SimpleCache; import java.io.File; +import androidx.annotation.NonNull; + /* package-private */ class CacheFactory implements DataSource.Factory { private static final String TAG = "CacheFactory"; @@ -25,7 +25,7 @@ import java.io.File; private static final int CACHE_FLAGS = CacheDataSource.FLAG_BLOCK_ON_CACHE | CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; - private final DefaultDataSourceFactory dataSourceFactory; + private final DataSource.Factory dataSourceFactory; private final File cacheDir; private final long maxFileSize; @@ -49,7 +49,9 @@ import java.io.File; final long maxFileSize) { this.maxFileSize = maxFileSize; - dataSourceFactory = new DefaultDataSourceFactory(context, userAgent, transferListener); + dataSourceFactory = new DefaultDataSource + .Factory(context, new DefaultHttpDataSource.Factory().setUserAgent(userAgent)) + .setTransferListener(transferListener); cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); if (!cacheDir.exists()) { //noinspection ResultOfMethodCallIgnored @@ -59,15 +61,16 @@ import java.io.File; if (cache == null) { final LeastRecentlyUsedCacheEvictor evictor = new LeastRecentlyUsedCacheEvictor(maxCacheSize); - cache = new SimpleCache(cacheDir, evictor, new ExoDatabaseProvider(context)); + cache = new SimpleCache(cacheDir, evictor, new StandaloneDatabaseProvider(context)); } } + @NonNull @Override public DataSource createDataSource() { Log.d(TAG, "initExoPlayerCache: cacheDir = " + cacheDir.getAbsolutePath()); - final DefaultDataSource dataSource = dataSourceFactory.createDataSource(); + final DataSource dataSource = dataSourceFactory.createDataSource(); final FileDataSource fileSource = new FileDataSource(); final CacheDataSink dataSink = new CacheDataSink(cache, maxFileSize); @@ -86,8 +89,8 @@ import java.io.File; Log.d(TAG, "tryDeleteCacheFiles: " + filePath + " deleted = " + deleteSuccessful); } - } catch (final Exception ignored) { - Log.e(TAG, "Failed to delete file.", ignored); + } catch (final Exception e) { + Log.e(TAG, "Failed to delete file.", e); } } } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java b/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java index 8d344c877..c12ba754a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java @@ -19,7 +19,6 @@ import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.player.mediasession.MediaSessionCallback; import org.schabi.newpipe.player.mediasession.PlayQueueNavigator; -import org.schabi.newpipe.player.mediasession.PlayQueuePlaybackController; import java.util.Optional; @@ -55,7 +54,6 @@ public class MediaSessionManager { .build()); sessionConnector = new MediaSessionConnector(mediaSession); - sessionConnector.setControlDispatcher(new PlayQueuePlaybackController(callback)); sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, callback)); sessionConnector.setPlayer(player); } @@ -135,9 +133,7 @@ public class MediaSessionManager { lastTitleHashCode = title.hashCode(); lastArtistHashCode = artist.hashCode(); lastDuration = duration; - if (optAlbumArt.isPresent()) { - lastAlbumArtHashCode = optAlbumArt.get().hashCode(); - } + optAlbumArt.ifPresent(bitmap -> lastAlbumArtHashCode = bitmap.hashCode()); } private boolean checkIfMetadataShouldBeSet( diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java index 5139ef9cd..1a55c21c3 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java @@ -9,6 +9,7 @@ import android.os.Bundle; import android.util.Log; import android.view.View; import android.widget.CheckBox; +import android.widget.RelativeLayout; import android.widget.SeekBar; import android.widget.TextView; @@ -19,6 +20,7 @@ import androidx.fragment.app.DialogFragment; import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; +import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; import org.schabi.newpipe.util.SliderStrategy; public class PlaybackParameterDialog extends DialogFragment { @@ -37,6 +39,7 @@ public class PlaybackParameterDialog extends DialogFragment { private static final double DEFAULT_TEMPO = 1.00f; private static final double DEFAULT_PITCH = 1.00f; + private static final int DEFAULT_SEMITONES = 0; private static final double DEFAULT_STEP = STEP_TWENTY_FIVE_PERCENT_VALUE; private static final boolean DEFAULT_SKIP_SILENCE = false; @@ -64,10 +67,11 @@ public class PlaybackParameterDialog extends DialogFragment { private double initialTempo = DEFAULT_TEMPO; private double initialPitch = DEFAULT_PITCH; + private int initialSemitones = DEFAULT_SEMITONES; private boolean initialSkipSilence = DEFAULT_SKIP_SILENCE; private double tempo = DEFAULT_TEMPO; private double pitch = DEFAULT_PITCH; - private double stepSize = DEFAULT_STEP; + private int semitones = DEFAULT_SEMITONES; @Nullable private SeekBar tempoSlider; @@ -86,9 +90,19 @@ public class PlaybackParameterDialog extends DialogFragment { @Nullable private TextView pitchStepUpText; @Nullable + private SeekBar semitoneSlider; + @Nullable + private TextView semitoneCurrentText; + @Nullable + private TextView semitoneStepDownText; + @Nullable + private TextView semitoneStepUpText; + @Nullable private CheckBox unhookingCheckbox; @Nullable private CheckBox skipSilenceCheckbox; + @Nullable + private CheckBox adjustBySemitonesCheckbox; public static PlaybackParameterDialog newInstance(final double playbackTempo, final double playbackPitch, @@ -101,6 +115,7 @@ public class PlaybackParameterDialog extends DialogFragment { dialog.tempo = playbackTempo; dialog.pitch = playbackPitch; + dialog.semitones = dialog.percentToSemitones(playbackPitch); dialog.initialSkipSilence = playbackSkipSilence; return dialog; @@ -111,7 +126,7 @@ public class PlaybackParameterDialog extends DialogFragment { //////////////////////////////////////////////////////////////////////////*/ @Override - public void onAttach(final Context context) { + public void onAttach(@NonNull final Context context) { super.onAttach(context); if (context instanceof Callback) { callback = (Callback) context; @@ -127,22 +142,22 @@ public class PlaybackParameterDialog extends DialogFragment { if (savedInstanceState != null) { initialTempo = savedInstanceState.getDouble(INITIAL_TEMPO_KEY, DEFAULT_TEMPO); initialPitch = savedInstanceState.getDouble(INITIAL_PITCH_KEY, DEFAULT_PITCH); + initialSemitones = percentToSemitones(initialPitch); tempo = savedInstanceState.getDouble(TEMPO_KEY, DEFAULT_TEMPO); pitch = savedInstanceState.getDouble(PITCH_KEY, DEFAULT_PITCH); - stepSize = savedInstanceState.getDouble(STEP_SIZE_KEY, DEFAULT_STEP); + semitones = percentToSemitones(pitch); } } @Override - public void onSaveInstanceState(final Bundle outState) { + public void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); outState.putDouble(INITIAL_TEMPO_KEY, initialTempo); outState.putDouble(INITIAL_PITCH_KEY, initialPitch); outState.putDouble(TEMPO_KEY, getCurrentTempo()); outState.putDouble(PITCH_KEY, getCurrentPitch()); - outState.putDouble(STEP_SIZE_KEY, getCurrentStepSize()); } /*////////////////////////////////////////////////////////////////////////// @@ -160,9 +175,11 @@ public class PlaybackParameterDialog extends DialogFragment { .setView(view) .setCancelable(true) .setNegativeButton(R.string.cancel, (dialogInterface, i) -> - setPlaybackParameters(initialTempo, initialPitch, initialSkipSilence)) + setPlaybackParameters(initialTempo, initialPitch, + initialSemitones, initialSkipSilence)) .setNeutralButton(R.string.playback_reset, (dialogInterface, i) -> - setPlaybackParameters(DEFAULT_TEMPO, DEFAULT_PITCH, DEFAULT_SKIP_SILENCE)) + setPlaybackParameters(DEFAULT_TEMPO, DEFAULT_PITCH, + DEFAULT_SEMITONES, DEFAULT_SKIP_SILENCE)) .setPositiveButton(R.string.ok, (dialogInterface, i) -> setCurrentPlaybackParameters()); @@ -176,14 +193,49 @@ public class PlaybackParameterDialog extends DialogFragment { private void setupControlViews(@NonNull final View rootView) { setupHookingControl(rootView); setupSkipSilenceControl(rootView); + setupAdjustBySemitonesControl(rootView); setupTempoControl(rootView); setupPitchControl(rootView); + setupSemitoneControl(rootView); + + togglePitchSliderType(rootView); - setStepSize(stepSize); setupStepSizeSelector(rootView); } + private void togglePitchSliderType(@NonNull final View rootView) { + final RelativeLayout pitchControl = rootView.findViewById(R.id.pitchControl); + final RelativeLayout semitoneControl = rootView.findViewById(R.id.semitoneControl); + + final View separatorStepSizeSelector = + rootView.findViewById(R.id.separatorStepSizeSelector); + final RelativeLayout.LayoutParams params = + (RelativeLayout.LayoutParams) separatorStepSizeSelector.getLayoutParams(); + if (pitchControl != null && semitoneControl != null && unhookingCheckbox != null) { + if (getCurrentAdjustBySemitones()) { + // replaces pitchControl slider with semitoneControl slider + pitchControl.setVisibility(View.GONE); + semitoneControl.setVisibility(View.VISIBLE); + params.addRule(RelativeLayout.BELOW, R.id.semitoneControl); + + // forces unhook for semitones + unhookingCheckbox.setChecked(true); + unhookingCheckbox.setEnabled(false); + + setupTempoStepSizeSelector(rootView); + } else { + semitoneControl.setVisibility(View.GONE); + pitchControl.setVisibility(View.VISIBLE); + params.addRule(RelativeLayout.BELOW, R.id.pitchControl); + + // (re)enables hooking selection + unhookingCheckbox.setEnabled(true); + setupCombinedStepSizeSelector(rootView); + } + } + } + private void setupTempoControl(@NonNull final View rootView) { tempoSlider = rootView.findViewById(R.id.tempoSeekbar); final TextView tempoMinimumText = rootView.findViewById(R.id.tempoMinimumText); @@ -234,23 +286,40 @@ public class PlaybackParameterDialog extends DialogFragment { } } + private void setupSemitoneControl(@NonNull final View rootView) { + semitoneSlider = rootView.findViewById(R.id.semitoneSeekbar); + semitoneCurrentText = rootView.findViewById(R.id.semitoneCurrentText); + semitoneStepDownText = rootView.findViewById(R.id.semitoneStepDown); + semitoneStepUpText = rootView.findViewById(R.id.semitoneStepUp); + + if (semitoneCurrentText != null) { + semitoneCurrentText.setText(getSignedSemitonesString(semitones)); + } + + if (semitoneSlider != null) { + setSemitoneSlider(semitones); + semitoneSlider.setOnSeekBarChangeListener(getOnSemitoneChangedListener()); + } + + } + private void setupHookingControl(@NonNull final View rootView) { unhookingCheckbox = rootView.findViewById(R.id.unhookCheckbox); if (unhookingCheckbox != null) { - // restore whether pitch and tempo are unhooked or not + // restores whether pitch and tempo are unhooked or not unhookingCheckbox.setChecked(PreferenceManager .getDefaultSharedPreferences(requireContext()) .getBoolean(getString(R.string.playback_unhook_key), true)); unhookingCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> { - // save whether pitch and tempo are unhooked or not + // saves whether pitch and tempo are unhooked or not PreferenceManager.getDefaultSharedPreferences(requireContext()) .edit() .putBoolean(getString(R.string.playback_unhook_key), isChecked) .apply(); if (!isChecked) { - // when unchecked, slide back to the minimum of current tempo or pitch + // when unchecked, slides back to the minimum of current tempo or pitch final double minimum = Math.min(getCurrentPitch(), getCurrentTempo()); setSliders(minimum); setCurrentPlaybackParameters(); @@ -268,7 +337,51 @@ public class PlaybackParameterDialog extends DialogFragment { } } + private void setupAdjustBySemitonesControl(@NonNull final View rootView) { + adjustBySemitonesCheckbox = rootView.findViewById(R.id.adjustBySemitonesCheckbox); + if (adjustBySemitonesCheckbox != null) { + // restores whether semitone adjustment is used or not + adjustBySemitonesCheckbox.setChecked(PreferenceManager + .getDefaultSharedPreferences(requireContext()) + .getBoolean(getString(R.string.playback_adjust_by_semitones_key), true)); + + // stores whether semitone adjustment is used or not + adjustBySemitonesCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> { + PreferenceManager.getDefaultSharedPreferences(requireContext()) + .edit() + .putBoolean(getString(R.string.playback_adjust_by_semitones_key), isChecked) + .apply(); + togglePitchSliderType(rootView); + if (isChecked) { + setPlaybackParameters( + getCurrentTempo(), + getCurrentPitch(), + Integer.min(12, + Integer.max(-12, percentToSemitones(getCurrentPitch()) + )), + getCurrentSkipSilence() + ); + setSemitoneSlider(Integer.min(12, + Integer.max(-12, percentToSemitones(getCurrentPitch())) + )); + } else { + setPlaybackParameters( + getCurrentTempo(), + semitonesToPercent(getCurrentSemitones()), + getCurrentSemitones(), + getCurrentSkipSilence() + ); + setPitchSlider(semitonesToPercent(getCurrentSemitones())); + } + }); + } + } + private void setupStepSizeSelector(@NonNull final View rootView) { + setStepSize(PreferenceManager + .getDefaultSharedPreferences(requireContext()) + .getFloat(getString(R.string.adjustment_step_key), (float) DEFAULT_STEP)); + final TextView stepSizeOnePercentText = rootView.findViewById(R.id.stepSizeOnePercent); final TextView stepSizeFivePercentText = rootView.findViewById(R.id.stepSizeFivePercent); final TextView stepSizeTenPercentText = rootView.findViewById(R.id.stepSizeTenPercent); @@ -310,8 +423,27 @@ public class PlaybackParameterDialog extends DialogFragment { } } + private void setupTempoStepSizeSelector(@NonNull final View rootView) { + final TextView playbackStepTypeText = rootView.findViewById(R.id.playback_step_type); + if (playbackStepTypeText != null) { + playbackStepTypeText.setText(R.string.playback_tempo_step); + } + setupStepSizeSelector(rootView); + } + + private void setupCombinedStepSizeSelector(@NonNull final View rootView) { + final TextView playbackStepTypeText = rootView.findViewById(R.id.playback_step_type); + if (playbackStepTypeText != null) { + playbackStepTypeText.setText(R.string.playback_step); + } + setupStepSizeSelector(rootView); + } + private void setStepSize(final double stepSize) { - this.stepSize = stepSize; + PreferenceManager.getDefaultSharedPreferences(requireContext()) + .edit() + .putFloat(getString(R.string.adjustment_step_key), (float) stepSize) + .apply(); if (tempoStepUpText != null) { tempoStepUpText.setText(getStepUpPercentString(stepSize)); @@ -344,16 +476,30 @@ public class PlaybackParameterDialog extends DialogFragment { setCurrentPlaybackParameters(); }); } + + if (semitoneStepDownText != null) { + semitoneStepDownText.setOnClickListener(view -> { + onSemitoneSliderUpdated(getCurrentSemitones() - 1); + setCurrentPlaybackParameters(); + }); + } + + if (semitoneStepUpText != null) { + semitoneStepUpText.setOnClickListener(view -> { + onSemitoneSliderUpdated(getCurrentSemitones() + 1); + setCurrentPlaybackParameters(); + }); + } } /*////////////////////////////////////////////////////////////////////////// // Sliders //////////////////////////////////////////////////////////////////////////*/ - private SeekBar.OnSeekBarChangeListener getOnTempoChangedListener() { - return new SeekBar.OnSeekBarChangeListener() { + private SimpleOnSeekBarChangeListener getOnTempoChangedListener() { + return new SimpleOnSeekBarChangeListener() { @Override - public void onProgressChanged(final SeekBar seekBar, final int progress, + public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress, final boolean fromUser) { final double currentTempo = strategy.valueOf(progress); if (fromUser) { @@ -361,23 +507,13 @@ public class PlaybackParameterDialog extends DialogFragment { setCurrentPlaybackParameters(); } } - - @Override - public void onStartTrackingTouch(final SeekBar seekBar) { - // Do Nothing. - } - - @Override - public void onStopTrackingTouch(final SeekBar seekBar) { - // Do Nothing. - } }; } - private SeekBar.OnSeekBarChangeListener getOnPitchChangedListener() { - return new SeekBar.OnSeekBarChangeListener() { + private SimpleOnSeekBarChangeListener getOnPitchChangedListener() { + return new SimpleOnSeekBarChangeListener() { @Override - public void onProgressChanged(final SeekBar seekBar, final int progress, + public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress, final boolean fromUser) { final double currentPitch = strategy.valueOf(progress); if (fromUser) { // this change is first in chain @@ -385,23 +521,27 @@ public class PlaybackParameterDialog extends DialogFragment { setCurrentPlaybackParameters(); } } + }; + } + private SimpleOnSeekBarChangeListener getOnSemitoneChangedListener() { + return new SimpleOnSeekBarChangeListener() { @Override - public void onStartTrackingTouch(final SeekBar seekBar) { - // Do Nothing. - } - - @Override - public void onStopTrackingTouch(final SeekBar seekBar) { - // Do Nothing. + public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress, + final boolean fromUser) { + // semitone slider supplies values 0 to 24, subtraction by 12 is required + final int currentSemitones = progress - 12; + if (fromUser) { // this change is first in chain + onSemitoneSliderUpdated(currentSemitones); + // line below also saves semitones as pitch percentages + onPitchSliderUpdated(semitonesToPercent(currentSemitones)); + setCurrentPlaybackParameters(); + } } }; } private void onTempoSliderUpdated(final double newTempo) { - if (unhookingCheckbox == null) { - return; - } if (!unhookingCheckbox.isChecked()) { setSliders(newTempo); } else { @@ -410,9 +550,6 @@ public class PlaybackParameterDialog extends DialogFragment { } private void onPitchSliderUpdated(final double newPitch) { - if (unhookingCheckbox == null) { - return; - } if (!unhookingCheckbox.isChecked()) { setSliders(newPitch); } else { @@ -420,6 +557,10 @@ public class PlaybackParameterDialog extends DialogFragment { } } + private void onSemitoneSliderUpdated(final int newSemitone) { + setSemitoneSlider(newSemitone); + } + private void setSliders(final double newValue) { setTempoSlider(newValue); setPitchSlider(newValue); @@ -439,25 +580,49 @@ public class PlaybackParameterDialog extends DialogFragment { pitchSlider.setProgress(strategy.progressOf(newPitch)); } + private void setSemitoneSlider(final int newSemitone) { + if (semitoneSlider == null) { + return; + } + semitoneSlider.setProgress(newSemitone + 12); + } + /*////////////////////////////////////////////////////////////////////////// // Helper //////////////////////////////////////////////////////////////////////////*/ private void setCurrentPlaybackParameters() { - setPlaybackParameters(getCurrentTempo(), getCurrentPitch(), getCurrentSkipSilence()); + if (getCurrentAdjustBySemitones()) { + setPlaybackParameters( + getCurrentTempo(), + semitonesToPercent(getCurrentSemitones()), + getCurrentSemitones(), + getCurrentSkipSilence() + ); + } else { + setPlaybackParameters( + getCurrentTempo(), + getCurrentPitch(), + percentToSemitones(getCurrentPitch()), + getCurrentSkipSilence() + ); + } } private void setPlaybackParameters(final double newTempo, final double newPitch, - final boolean skipSilence) { - if (callback != null && tempoCurrentText != null && pitchCurrentText != null) { + final int newSemitones, final boolean skipSilence) { + if (callback != null && tempoCurrentText != null + && pitchCurrentText != null && semitoneCurrentText != null) { if (DEBUG) { Log.d(TAG, "Setting playback parameters to " + "tempo=[" + newTempo + "], " - + "pitch=[" + newPitch + "]"); + + "pitch=[" + newPitch + "], " + + "semitones=[" + newSemitones + "]"); } tempoCurrentText.setText(PlayerHelper.formatSpeed(newTempo)); pitchCurrentText.setText(PlayerHelper.formatPitch(newPitch)); + semitoneCurrentText.setText(getSignedSemitonesString(newSemitones)); callback.onPlaybackParameterChanged((float) newTempo, (float) newPitch, skipSilence); } } @@ -470,14 +635,19 @@ public class PlaybackParameterDialog extends DialogFragment { return pitchSlider == null ? pitch : strategy.valueOf(pitchSlider.getProgress()); } - private double getCurrentStepSize() { - return stepSize; + private int getCurrentSemitones() { + // semitoneSlider is absolute, that's why - 12 + return semitoneSlider == null ? semitones : semitoneSlider.getProgress() - 12; } private boolean getCurrentSkipSilence() { return skipSilenceCheckbox != null && skipSilenceCheckbox.isChecked(); } + private boolean getCurrentAdjustBySemitones() { + return adjustBySemitonesCheckbox != null && adjustBySemitonesCheckbox.isChecked(); + } + @NonNull private static String getStepUpPercentString(final double percent) { return STEP_UP_SIGN + getPercentString(percent); @@ -493,8 +663,21 @@ public class PlaybackParameterDialog extends DialogFragment { return PlayerHelper.formatPitch(percent); } + @NonNull + private static String getSignedSemitonesString(final int semitones) { + return semitones > 0 ? "+" + semitones : "" + semitones; + } + public interface Callback { void onPlaybackParameterChanged(float playbackTempo, float playbackPitch, boolean playbackSkipSilence); } + + public double semitonesToPercent(final int inSemitones) { + return Math.pow(2, inSemitones / 12.0); + } + + public int percentToSemitones(final double inPercent) { + return (int) Math.round(12 * Math.log(inPercent) / Math.log(2)); + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java index a2f0d7149..405f6fd37 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java @@ -2,8 +2,6 @@ package org.schabi.newpipe.player.helper; import android.content.Context; -import androidx.annotation.NonNull; - import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.source.SingleSampleMediaSource; import com.google.android.exoplayer2.source.dash.DashMediaSource; @@ -13,10 +11,13 @@ import com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistTrack import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource; import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; import com.google.android.exoplayer2.upstream.DataSource; -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.DefaultDataSource; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.TransferListener; +import androidx.annotation.NonNull; + public class PlayerDataSource { public static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000; @@ -31,14 +32,18 @@ public class PlayerDataSource { private static final int MANIFEST_MINIMUM_RETRY = 5; private static final int EXTRACTOR_MINIMUM_RETRY = Integer.MAX_VALUE; + private final int continueLoadingCheckIntervalBytes; private final DataSource.Factory cacheDataSourceFactory; private final DataSource.Factory cachelessDataSourceFactory; - public PlayerDataSource(@NonNull final Context context, @NonNull final String userAgent, + public PlayerDataSource(@NonNull final Context context, + @NonNull final String userAgent, @NonNull final TransferListener transferListener) { + continueLoadingCheckIntervalBytes = PlayerHelper.getProgressiveLoadIntervalBytes(context); cacheDataSourceFactory = new CacheFactory(context, userAgent, transferListener); - cachelessDataSourceFactory - = new DefaultDataSourceFactory(context, userAgent, transferListener); + cachelessDataSourceFactory = new DefaultDataSource + .Factory(context, new DefaultHttpDataSource.Factory().setUserAgent(userAgent)) + .setTransferListener(transferListener); } public SsMediaSource.Factory getLiveSsMediaSourceFactory() { @@ -91,6 +96,7 @@ public class PlayerDataSource { public ProgressiveMediaSource.Factory getExtractorMediaSourceFactory() { return new ProgressiveMediaSource.Factory(cacheDataSourceFactory) + .setContinueLoadingCheckIntervalBytes(continueLoadingCheckIntervalBytes) .setLoadErrorHandlingPolicy( new DefaultLoadErrorHandlingPolicy(EXTRACTOR_MINIMUM_RETRY)); } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java index c51b6d5dd..b73c6cf7f 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java @@ -34,6 +34,7 @@ import androidx.preference.PreferenceManager; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player.RepeatMode; import com.google.android.exoplayer2.SeekParameters; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; import com.google.android.exoplayer2.trackselection.ExoTrackSelection; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; @@ -77,6 +78,20 @@ public final class PlayerHelper { private static final NumberFormat SPEED_FORMATTER = new DecimalFormat("0.##x"); private static final NumberFormat PITCH_FORMATTER = new DecimalFormat("##%"); + /** + * Maximum opacity allowed for Android 12 and higher to allow touches on other apps when using + * NewPipe's popup player. + * + *

+ * This value is hardcoded instead of being get dynamically with the method linked of the + * constant documentation below, because it is not static and popup player layout parameters + * are generated with static methods. + *

+ * + * @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE + */ + private static final float MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER = 0.8f; + @Retention(SOURCE) @IntDef({AUTOPLAY_TYPE_ALWAYS, AUTOPLAY_TYPE_WIFI, AUTOPLAY_TYPE_NEVER}) @@ -143,6 +158,21 @@ public final class PlayerHelper { ? " (" + context.getString(R.string.caption_auto_generated) + ")" : ""); } + @NonNull + public static String captionLanguageStemOf(@NonNull final String language) { + if (!language.contains("(") || !language.contains(")")) { + return language; + } + + if (language.startsWith("(")) { + // language text is right-to-left + final String[] parts = language.split("\\)"); + return parts[parts.length - 1].trim(); + } + + return language.split("\\(")[0].trim(); + } + @NonNull public static String resizeTypeOf(@NonNull final Context context, @ResizeMode final int resizeMode) { @@ -391,6 +421,19 @@ public final class PlayerHelper { context.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) == 0; } + public static int getProgressiveLoadIntervalBytes(@NonNull final Context context) { + final String preferredIntervalBytes = getPreferences(context).getString( + context.getString(R.string.progressive_load_interval_key), + context.getString(R.string.progressive_load_interval_default_value)); + + if (context.getString(R.string.progressive_load_interval_exoplayer_default_value) + .equals(preferredIntervalBytes)) { + return ProgressiveMediaSource.DEFAULT_LOADING_CHECK_INTERVAL_BYTES; + } + // Keeping the same KiB unit used by ProgressiveMediaSource + return Integer.parseInt(preferredIntervalBytes) * 1024; + } + //////////////////////////////////////////////////////////////////////////// // Private helpers //////////////////////////////////////////////////////////////////////////// @@ -558,6 +601,12 @@ public final class PlayerHelper { flags, PixelFormat.TRANSLUCENT); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Setting maximum opacity allowed for touch events to other apps for Android 12 and + // higher to prevent non interaction when using other apps with the popup player + closeOverlayLayoutParams.alpha = MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER; + } + closeOverlayLayoutParams.gravity = Gravity.LEFT | Gravity.TOP; closeOverlayLayoutParams.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE; diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java index 06a2e52ab..4c09ed3c1 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java @@ -10,7 +10,7 @@ import android.util.Log; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; -import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.PlaybackException; import com.google.android.exoplayer2.PlaybackParameters; import org.schabi.newpipe.App; @@ -233,9 +233,10 @@ public final class PlayerHolder { } @Override - public void onPlayerError(final ExoPlaybackException error) { + public void onPlayerError(final PlaybackException error, + final boolean isCatchableException) { if (listener != null) { - listener.onPlayerError(error); + listener.onPlayerError(error, isCatchableException); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/listeners/view/PlaybackSpeedClickListener.kt b/app/src/main/java/org/schabi/newpipe/player/listeners/view/PlaybackSpeedClickListener.kt new file mode 100644 index 000000000..52eff5a1c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/listeners/view/PlaybackSpeedClickListener.kt @@ -0,0 +1,47 @@ +package org.schabi.newpipe.player.listeners.view + +import android.util.Log +import android.view.View +import androidx.appcompat.widget.PopupMenu +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.player.Player +import org.schabi.newpipe.player.helper.PlaybackParameterDialog + +/** + * Click listener for the playbackSpeed textview of the player + */ +class PlaybackSpeedClickListener( + private val player: Player, + private val playbackSpeedPopupMenu: PopupMenu +) : View.OnClickListener { + + companion object { + private const val TAG: String = "PlaybSpeedClickListener" + } + + override fun onClick(v: View) { + if (MainActivity.DEBUG) { + Log.d(TAG, "onPlaybackSpeedClicked() called") + } + + if (player.videoPlayerSelected()) { + PlaybackParameterDialog.newInstance( + player.playbackSpeed.toDouble(), + player.playbackPitch.toDouble(), + player.playbackSkipSilence + ) { speed: Float, pitch: Float, skipSilence: Boolean -> + player.setPlaybackParameters( + speed, + pitch, + skipSilence + ) + } + .show(player.parentActivity!!.supportFragmentManager, null) + } else { + playbackSpeedPopupMenu.show() + player.isSomePopupMenuVisible = true + } + + player.manageControlsAfterOnClick(v) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/listeners/view/QualityClickListener.kt b/app/src/main/java/org/schabi/newpipe/player/listeners/view/QualityClickListener.kt new file mode 100644 index 000000000..b103ac0e6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/listeners/view/QualityClickListener.kt @@ -0,0 +1,41 @@ +package org.schabi.newpipe.player.listeners.view + +import android.annotation.SuppressLint +import android.util.Log +import android.view.View +import androidx.appcompat.widget.PopupMenu +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.extractor.MediaFormat +import org.schabi.newpipe.player.Player + +/** + * Click listener for the qualityTextView of the player + */ +class QualityClickListener( + private val player: Player, + private val qualityPopupMenu: PopupMenu +) : View.OnClickListener { + + companion object { + private const val TAG: String = "QualityClickListener" + } + + @SuppressLint("SetTextI18n") // we don't need I18N because of a " " + override fun onClick(v: View) { + if (MainActivity.DEBUG) { + Log.d(TAG, "onQualitySelectorClicked() called") + } + + qualityPopupMenu.show() + player.isSomePopupMenuVisible = true + + val videoStream = player.selectedVideoStream + if (videoStream != null) { + player.binding.qualityTextView.text = + MediaFormat.getNameById(videoStream.formatId) + " " + videoStream.resolution + } + + player.saveWasPlaying() + player.manageControlsAfterOnClick(v) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.java new file mode 100644 index 000000000..ebedf8c71 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.java @@ -0,0 +1,99 @@ +package org.schabi.newpipe.player.mediaitem; + +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipe.player.playqueue.PlayQueueItem; + +import java.util.List; +import java.util.Optional; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * This {@link MediaItemTag} object is designed to contain metadata for a stream + * that has failed to load. It supplies metadata from an underlying + * {@link PlayQueueItem}, which is used by the internal players to resolve actual + * playback info. + * + * This {@link MediaItemTag} does not contain any {@link StreamInfo} that can be + * used to start playback and can be detected by checking {@link ExceptionTag#getErrors()} + * when in generic form. + **/ +public final class ExceptionTag implements MediaItemTag { + @NonNull + private final PlayQueueItem item; + @NonNull + private final List errors; + @Nullable + private final Object extras; + + private ExceptionTag(@NonNull final PlayQueueItem item, + @NonNull final List errors, + @Nullable final Object extras) { + this.item = item; + this.errors = errors; + this.extras = extras; + } + + public static ExceptionTag of(@NonNull final PlayQueueItem playQueueItem, + @NonNull final List errors) { + return new ExceptionTag(playQueueItem, errors, null); + } + + @NonNull + @Override + public List getErrors() { + return errors; + } + + @Override + public int getServiceId() { + return item.getServiceId(); + } + + @Override + public String getTitle() { + return item.getTitle(); + } + + @Override + public String getUploaderName() { + return item.getUploader(); + } + + @Override + public long getDurationSeconds() { + return item.getDuration(); + } + + @Override + public String getStreamUrl() { + return item.getUrl(); + } + + @Override + public String getThumbnailUrl() { + return item.getThumbnailUrl(); + } + + @Override + public String getUploaderUrl() { + return item.getUploaderUrl(); + } + + @Override + public StreamType getStreamType() { + return item.getStreamType(); + } + + @Override + public Optional getMaybeExtras(@NonNull final Class type) { + return Optional.ofNullable(extras).map(type::cast); + } + + @Override + public MediaItemTag withExtras(@NonNull final T extra) { + return new ExceptionTag(item, errors, extra); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java new file mode 100644 index 000000000..f84b0383a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java @@ -0,0 +1,127 @@ +package org.schabi.newpipe.player.mediaitem; + +import android.net.Uri; + +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.MediaMetadata; +import com.google.android.exoplayer2.Player; + +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipe.extractor.stream.VideoStream; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Metadata container and accessor used by player internals. + * + * This interface ensures consistency of fetching metadata on each stream, + * which is encapsulated in a {@link MediaItem} and delivered via ExoPlayer's + * {@link Player.Listener} on event triggers to the downstream users. + **/ +public interface MediaItemTag { + + List getErrors(); + + int getServiceId(); + + String getTitle(); + + String getUploaderName(); + + long getDurationSeconds(); + + String getStreamUrl(); + + String getThumbnailUrl(); + + String getUploaderUrl(); + + StreamType getStreamType(); + + @NonNull + default Optional getMaybeStreamInfo() { + return Optional.empty(); + } + + @NonNull + default Optional getMaybeQuality() { + return Optional.empty(); + } + + Optional getMaybeExtras(@NonNull Class type); + + MediaItemTag withExtras(@NonNull T extra); + + @NonNull + static Optional from(@Nullable final MediaItem mediaItem) { + if (mediaItem == null || mediaItem.localConfiguration == null + || !(mediaItem.localConfiguration.tag instanceof MediaItemTag)) { + return Optional.empty(); + } + + return Optional.of((MediaItemTag) mediaItem.localConfiguration.tag); + } + + @NonNull + default String makeMediaId() { + return UUID.randomUUID().toString() + "[" + getTitle() + "]"; + } + + @NonNull + default MediaItem asMediaItem() { + final MediaMetadata mediaMetadata = new MediaMetadata.Builder() + .setMediaUri(Uri.parse(getStreamUrl())) + .setArtworkUri(Uri.parse(getThumbnailUrl())) + .setArtist(getUploaderName()) + .setDescription(getTitle()) + .setDisplayTitle(getTitle()) + .setTitle(getTitle()) + .build(); + + return MediaItem.fromUri(getStreamUrl()) + .buildUpon() + .setMediaId(makeMediaId()) + .setMediaMetadata(mediaMetadata) + .setTag(this) + .build(); + } + + final class Quality { + @NonNull + private final List sortedVideoStreams; + private final int selectedVideoStreamIndex; + + private Quality(@NonNull final List sortedVideoStreams, + final int selectedVideoStreamIndex) { + this.sortedVideoStreams = sortedVideoStreams; + this.selectedVideoStreamIndex = selectedVideoStreamIndex; + } + + static Quality of(@NonNull final List sortedVideoStreams, + final int selectedVideoStreamIndex) { + return new Quality(sortedVideoStreams, selectedVideoStreamIndex); + } + + @NonNull + public List getSortedVideoStreams() { + return sortedVideoStreams; + } + + public int getSelectedVideoStreamIndex() { + return selectedVideoStreamIndex; + } + + @Nullable + public VideoStream getSelectedVideoStream() { + return selectedVideoStreamIndex < 0 + || selectedVideoStreamIndex >= sortedVideoStreams.size() + ? null : sortedVideoStreams.get(selectedVideoStreamIndex); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/PlaceholderTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/PlaceholderTag.java new file mode 100644 index 000000000..cce4e9f17 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/PlaceholderTag.java @@ -0,0 +1,85 @@ +package org.schabi.newpipe.player.mediaitem; + +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipe.util.Constants; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * This is a Placeholding {@link MediaItemTag}, designed as a dummy metadata object for + * any stream that has not been resolved. + * + * This object cannot be instantiated and does not hold real metadata of any form. + * */ +public final class PlaceholderTag implements MediaItemTag { + public static final PlaceholderTag EMPTY = new PlaceholderTag(null); + private static final String UNKNOWN_VALUE_INTERNAL = "Placeholder"; + + @Nullable + private final Object extras; + + private PlaceholderTag(@Nullable final Object extras) { + this.extras = extras; + } + + @NonNull + @Override + public List getErrors() { + return Collections.emptyList(); + } + + @Override + public int getServiceId() { + return Constants.NO_SERVICE_ID; + } + + @Override + public String getTitle() { + return UNKNOWN_VALUE_INTERNAL; + } + + @Override + public String getUploaderName() { + return UNKNOWN_VALUE_INTERNAL; + } + + @Override + public long getDurationSeconds() { + return 0; + } + + @Override + public String getStreamUrl() { + return UNKNOWN_VALUE_INTERNAL; + } + + @Override + public String getThumbnailUrl() { + return UNKNOWN_VALUE_INTERNAL; + } + + @Override + public String getUploaderUrl() { + return UNKNOWN_VALUE_INTERNAL; + } + + @Override + public StreamType getStreamType() { + return StreamType.NONE; + } + + @Override + public Optional getMaybeExtras(@NonNull final Class type) { + return Optional.ofNullable(extras).map(type::cast); + } + + @Override + public MediaItemTag withExtras(@NonNull final T extra) { + return new PlaceholderTag(extra); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java new file mode 100644 index 000000000..4095f2bc8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java @@ -0,0 +1,115 @@ +package org.schabi.newpipe.player.mediaitem; + +import com.google.android.exoplayer2.MediaItem; + +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipe.extractor.stream.VideoStream; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * This {@link MediaItemTag} object contains metadata for a resolved stream + * that is ready for playback. This object guarantees the {@link StreamInfo} + * is available and may provide the {@link Quality} of video stream used in + * the {@link MediaItem}. + **/ +public final class StreamInfoTag implements MediaItemTag { + @NonNull + private final StreamInfo streamInfo; + @Nullable + private final MediaItemTag.Quality quality; + @Nullable + private final Object extras; + + private StreamInfoTag(@NonNull final StreamInfo streamInfo, + @Nullable final MediaItemTag.Quality quality, + @Nullable final Object extras) { + this.streamInfo = streamInfo; + this.quality = quality; + this.extras = extras; + } + + public static StreamInfoTag of(@NonNull final StreamInfo streamInfo, + @NonNull final List sortedVideoStreams, + final int selectedVideoStreamIndex) { + final Quality quality = Quality.of(sortedVideoStreams, selectedVideoStreamIndex); + return new StreamInfoTag(streamInfo, quality, null); + } + + public static StreamInfoTag of(@NonNull final StreamInfo streamInfo) { + return new StreamInfoTag(streamInfo, null, null); + } + + @Override + public List getErrors() { + return Collections.emptyList(); + } + + @Override + public int getServiceId() { + return streamInfo.getServiceId(); + } + + @Override + public String getTitle() { + return streamInfo.getName(); + } + + @Override + public String getUploaderName() { + return streamInfo.getUploaderName(); + } + + @Override + public long getDurationSeconds() { + return streamInfo.getDuration(); + } + + @Override + public String getStreamUrl() { + return streamInfo.getUrl(); + } + + @Override + public String getThumbnailUrl() { + return streamInfo.getThumbnailUrl(); + } + + @Override + public String getUploaderUrl() { + return streamInfo.getUploaderUrl(); + } + + @Override + public StreamType getStreamType() { + return streamInfo.getStreamType(); + } + + @NonNull + @Override + public Optional getMaybeStreamInfo() { + return Optional.of(streamInfo); + } + + @NonNull + @Override + public Optional getMaybeQuality() { + return Optional.ofNullable(quality); + } + + @Override + public Optional getMaybeExtras(@NonNull final Class type) { + return Optional.ofNullable(extras).map(type::cast); + } + + @Override + public StreamInfoTag withExtras(@NonNull final Object extra) { + return new StreamInfoTag(streamInfo, quality, extra); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java index 62664c827..92cd425c5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java @@ -7,7 +7,6 @@ import android.support.v4.media.session.MediaSessionCompat; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.ControlDispatcher; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; import com.google.android.exoplayer2.util.Util; @@ -44,17 +43,17 @@ public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator } @Override - public void onTimelineChanged(final Player player) { + public void onTimelineChanged(@NonNull final Player player) { publishFloatingQueueWindow(); } @Override - public void onCurrentWindowIndexChanged(final Player player) { + public void onCurrentMediaItemIndexChanged(@NonNull final Player player) { if (activeQueueItemId == MediaSessionCompat.QueueItem.UNKNOWN_ID || player.getCurrentTimeline().getWindowCount() > maxQueueSize) { publishFloatingQueueWindow(); } else if (!player.getCurrentTimeline().isEmpty()) { - activeQueueItemId = player.getCurrentWindowIndex(); + activeQueueItemId = player.getCurrentMediaItemIndex(); } } @@ -64,18 +63,17 @@ public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator } @Override - public void onSkipToPrevious(final Player player, final ControlDispatcher controlDispatcher) { + public void onSkipToPrevious(@NonNull final Player player) { callback.playPrevious(); } @Override - public void onSkipToQueueItem(final Player player, final ControlDispatcher controlDispatcher, - final long id) { + public void onSkipToQueueItem(@NonNull final Player player, final long id) { callback.playItemAtIndex((int) id); } @Override - public void onSkipToNext(final Player player, final ControlDispatcher controlDispatcher) { + public void onSkipToNext(@NonNull final Player player) { callback.playNext(); } @@ -102,8 +100,10 @@ public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator } @Override - public boolean onCommand(final Player player, final ControlDispatcher controlDispatcher, - final String command, final Bundle extras, final ResultReceiver cb) { + public boolean onCommand(@NonNull final Player player, + @NonNull final String command, + @Nullable final Bundle extras, + @Nullable final ResultReceiver cb) { return false; } } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueuePlaybackController.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueuePlaybackController.java deleted file mode 100644 index 8bfbcde6b..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueuePlaybackController.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.schabi.newpipe.player.mediasession; - -import com.google.android.exoplayer2.DefaultControlDispatcher; -import com.google.android.exoplayer2.Player; - -public class PlayQueuePlaybackController extends DefaultControlDispatcher { - private final MediaSessionCallback callback; - - public PlayQueuePlaybackController(final MediaSessionCallback callback) { - super(); - this.callback = callback; - } - - @Override - public boolean dispatchSetPlayWhenReady(final Player player, final boolean playWhenReady) { - if (playWhenReady) { - callback.play(); - } else { - callback.pause(); - } - return true; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java index 7594f3a16..8aad356d0 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java @@ -2,52 +2,83 @@ package org.schabi.newpipe.player.mediasource; import android.util.Log; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.PlaybackException; +import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.BaseMediaSource; import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.SilenceMediaSource; +import com.google.android.exoplayer2.source.SinglePeriodTimeline; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.TransferListener; +import org.schabi.newpipe.player.mediaitem.ExceptionTag; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import java.io.IOException; +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; public class FailedMediaSource extends BaseMediaSource implements ManagedMediaSource { + /** + * Play 2 seconds of silenced audio when a stream fails to resolve due to a known issue, + * such as {@link org.schabi.newpipe.extractor.exceptions.ExtractionException}. + * + * This silence duration allows user to react and have time to jump to a previous stream, + * while still provide a smooth playback experience. A duration lower than 1 second is + * not recommended, it may cause ExoPlayer to buffer for a while. + * */ + public static final long SILENCE_DURATION_US = TimeUnit.SECONDS.toMicros(2); + public static final MediaPeriod SILENT_MEDIA = makeSilentMediaPeriod(SILENCE_DURATION_US); + private final String TAG = "FailedMediaSource@" + Integer.toHexString(hashCode()); private final PlayQueueItem playQueueItem; - private final FailedMediaSourceException error; + private final Exception error; private final long retryTimestamp; - + private final MediaItem mediaItem; + /** + * Fail the play queue item associated with this source, with potential future retries. + * + * The error will be propagated if the cause for load exception is unspecified. + * This means the error might be caused by reasons outside of extraction (e.g. no network). + * Otherwise, a silenced stream will play instead. + * + * @param playQueueItem play queue item + * @param error exception that was the reason to fail + * @param retryTimestamp epoch timestamp when this MediaSource can be refreshed + */ public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem, - @NonNull final FailedMediaSourceException error, + @NonNull final Exception error, final long retryTimestamp) { this.playQueueItem = playQueueItem; this.error = error; this.retryTimestamp = retryTimestamp; + this.mediaItem = ExceptionTag + .of(playQueueItem, Collections.singletonList(error)) + .withExtras(this) + .asMediaItem(); } - /** - * Permanently fail the play queue item associated with this source, with no hope of retrying. - * The error will always be propagated to ExoPlayer. - * - * @param playQueueItem play queue item - * @param error exception that was the reason to fail - */ - public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem, - @NonNull final FailedMediaSourceException error) { - this.playQueueItem = playQueueItem; - this.error = error; - this.retryTimestamp = Long.MAX_VALUE; + public static FailedMediaSource of(@NonNull final PlayQueueItem playQueueItem, + @NonNull final FailedMediaSourceException error) { + return new FailedMediaSource(playQueueItem, error, Long.MAX_VALUE); + } + + public static FailedMediaSource of(@NonNull final PlayQueueItem playQueueItem, + @NonNull final Exception error, + final long retryWaitMillis) { + return new FailedMediaSource(playQueueItem, error, + System.currentTimeMillis() + retryWaitMillis); } public PlayQueueItem getStream() { return playQueueItem; } - public FailedMediaSourceException getError() { + public Exception getError() { return error; } @@ -55,35 +86,78 @@ public class FailedMediaSource extends BaseMediaSource implements ManagedMediaSo return System.currentTimeMillis() >= retryTimestamp; } - /** - * Returns the {@link MediaItem} whose media is provided by the source. - */ @Override public MediaItem getMediaItem() { - return MediaItem.fromUri(playQueueItem.getUrl()); + return mediaItem; } - @Override - public void maybeThrowSourceInfoRefreshError() throws IOException { - throw new IOException(error); - } - - @Override - public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator, - final long startPositionUs) { - return null; - } - - @Override - public void releasePeriod(final MediaPeriod mediaPeriod) { } - + /** + * Prepares the source with {@link Timeline} info on the silence playback when the error + * is classed as {@link FailedMediaSourceException}, for example, when the error is + * {@link org.schabi.newpipe.extractor.exceptions.ExtractionException ExtractionException}. + * These types of error are swallowed by {@link FailedMediaSource}, and the underlying + * exception is carried to the {@link MediaItem} metadata during playback. + *

+ * If the exception is not known, e.g. {@link java.net.UnknownHostException} or some + * other network issue, then no source info is refreshed and + * {@link #maybeThrowSourceInfoRefreshError()} be will triggered. + *

+ * Note that this method is called only once until {@link #releaseSourceInternal()} is called, + * so if no action is done in here, playback will stall unless + * {@link #maybeThrowSourceInfoRefreshError()} is called. + * + * @param mediaTransferListener No data transfer listener needed, ignored here. + */ @Override protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) { Log.e(TAG, "Loading failed source: ", error); + if (error instanceof FailedMediaSourceException) { + refreshSourceInfo(makeSilentMediaTimeline(SILENCE_DURATION_US, mediaItem)); + } + } + + /** + * If the error is not known, e.g. network issue, then the exception is not swallowed here in + * {@link FailedMediaSource}. The exception is then propagated to the player, which + * {@link org.schabi.newpipe.player.Player Player} can react to inside + * {@link com.google.android.exoplayer2.Player.Listener#onPlayerError(PlaybackException)}. + * + * @throws IOException An error which will always result in + * {@link com.google.android.exoplayer2.PlaybackException#ERROR_CODE_IO_UNSPECIFIED}. + */ + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + if (!(error instanceof FailedMediaSourceException)) { + throw new IOException(error); + } + } + + /** + * This method is only called if {@link #prepareSourceInternal(TransferListener)} + * refreshes the source info with no exception. All parameters are ignored as this + * returns a static and reused piece of silent audio. + * + * @param id The identifier of the period. + * @param allocator An {@link Allocator} from which to obtain media buffer allocations. + * @param startPositionUs The expected start position, in microseconds. + * @return The common {@link MediaPeriod} holding the silence. + */ + @Override + public MediaPeriod createPeriod(final MediaPeriodId id, + final Allocator allocator, + final long startPositionUs) { + return SILENT_MEDIA; } @Override - protected void releaseSourceInternal() { } + public void releasePeriod(final MediaPeriod mediaPeriod) { + /* Do Nothing (we want to keep re-using the Silent MediaPeriod) */ + } + + @Override + protected void releaseSourceInternal() { + /* Do Nothing, no clean-up for processing/extra thread is needed by this MediaSource */ + } @Override public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity, @@ -117,4 +191,22 @@ public class FailedMediaSource extends BaseMediaSource implements ManagedMediaSo super(cause); } } + + private static Timeline makeSilentMediaTimeline(final long durationUs, + @NonNull final MediaItem mediaItem) { + return new SinglePeriodTimeline( + durationUs, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* useLiveConfiguration= */ false, + /* manifest= */ null, + mediaItem); + } + + private static MediaPeriod makeSilentMediaPeriod(final long durationUs) { + return new SilenceMediaSource.Factory() + .setDurationUs(durationUs) + .createMediaSource() + .createPeriod(null, null, 0); + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java index 746a97581..95524cf69 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java @@ -1,32 +1,46 @@ package org.schabi.newpipe.player.mediasource; -import android.os.Handler; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.CompositeMediaSource; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.TransferListener; + +import org.schabi.newpipe.player.mediaitem.MediaItemTag; +import org.schabi.newpipe.player.playqueue.PlayQueueItem; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.drm.DrmSessionEventListener; -import com.google.android.exoplayer2.source.MediaPeriod; -import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.MediaSourceEventListener; -import com.google.android.exoplayer2.upstream.Allocator; -import com.google.android.exoplayer2.upstream.TransferListener; - -import org.schabi.newpipe.player.playqueue.PlayQueueItem; - -import java.io.IOException; - -public class LoadedMediaSource implements ManagedMediaSource { +public class LoadedMediaSource extends CompositeMediaSource implements ManagedMediaSource { private final MediaSource source; private final PlayQueueItem stream; + private final MediaItem mediaItem; private final long expireTimestamp; - public LoadedMediaSource(@NonNull final MediaSource source, @NonNull final PlayQueueItem stream, + /** + * Uses a {@link CompositeMediaSource} to wrap one or more child {@link MediaSource}s + * containing actual media. This wrapper {@link LoadedMediaSource} holds the expiration + * timestamp as a {@link ManagedMediaSource} to allow explicit playlist management under + * {@link ManagedMediaSourcePlaylist}. + * + * @param source The child media source with actual media. + * @param tag Metadata for the child media source. + * @param stream The queue item associated with the media source. + * @param expireTimestamp The timestamp when the media source expires and might not be + * available for playback. + */ + public LoadedMediaSource(@NonNull final MediaSource source, + @NonNull final MediaItemTag tag, + @NonNull final PlayQueueItem stream, final long expireTimestamp) { this.source = source; this.stream = stream; this.expireTimestamp = expireTimestamp; + + this.mediaItem = tag.withExtras(this).asMediaItem(); } public PlayQueueItem getStream() { @@ -37,20 +51,38 @@ public class LoadedMediaSource implements ManagedMediaSource { return System.currentTimeMillis() >= expireTimestamp; } + /** + * Delegates the preparation of child {@link MediaSource}s to the + * {@link CompositeMediaSource} wrapper. Since all {@link LoadedMediaSource}s use only + * a single child media, the child id of 0 is always used (sonar doesn't like null as id here). + * + * @param mediaTransferListener A data transfer listener that will be registered by the + * {@link CompositeMediaSource} for child source preparation. + */ @Override - public void prepareSource(final MediaSourceCaller mediaSourceCaller, - @Nullable final TransferListener mediaTransferListener) { - source.prepareSource(mediaSourceCaller, mediaTransferListener); + protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + prepareChildSource(0, source); } + /** + * When any child {@link MediaSource} is prepared, the refreshed {@link Timeline} can + * be listened to here. But since {@link LoadedMediaSource} has only a single child source, + * this method is called only once until {@link #releaseSourceInternal()} is called. + *

+ * On refresh, the {@link CompositeMediaSource} delegate will be notified with the + * new {@link Timeline}, otherwise {@link #createPeriod(MediaPeriodId, Allocator, long)} + * will not be called and playback may be stalled. + * + * @param id The unique id used to prepare the child source. + * @param mediaSource The child source whose source info has been refreshed. + * @param timeline The new timeline of the child source. + */ @Override - public void maybeThrowSourceInfoRefreshError() throws IOException { - source.maybeThrowSourceInfoRefreshError(); - } - - @Override - public void enable(final MediaSourceCaller caller) { - source.enable(caller); + protected void onChildSourceInfoRefreshed(final Integer id, + final MediaSource mediaSource, + final Timeline timeline) { + refreshSourceInfo(timeline); } @Override @@ -64,57 +96,10 @@ public class LoadedMediaSource implements ManagedMediaSource { source.releasePeriod(mediaPeriod); } - @Override - public void disable(final MediaSourceCaller caller) { - source.disable(caller); - } - - @Override - public void releaseSource(final MediaSourceCaller mediaSourceCaller) { - source.releaseSource(mediaSourceCaller); - } - - @Override - public void addEventListener(final Handler handler, - final MediaSourceEventListener eventListener) { - source.addEventListener(handler, eventListener); - } - - @Override - public void removeEventListener(final MediaSourceEventListener eventListener) { - source.removeEventListener(eventListener); - } - - /** - * Adds a {@link DrmSessionEventListener} to the list of listeners which are notified of DRM - * events for this media source. - * - * @param handler A handler on the which listener events will be posted. - * @param eventListener The listener to be added. - */ - @Override - public void addDrmEventListener(final Handler handler, - final DrmSessionEventListener eventListener) { - source.addDrmEventListener(handler, eventListener); - } - - /** - * Removes a {@link DrmSessionEventListener} from the list of listeners which are notified of - * DRM events for this media source. - * - * @param eventListener The listener to be removed. - */ - @Override - public void removeDrmEventListener(final DrmSessionEventListener eventListener) { - source.removeDrmEventListener(eventListener); - } - - /** - * Returns the {@link MediaItem} whose media is provided by the source. - */ + @NonNull @Override public MediaItem getMediaItem() { - return source.getMediaItem(); + return mediaItem; } @Override diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java index 21fddbe86..9d6b94893 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java @@ -1,7 +1,6 @@ package org.schabi.newpipe.player.mediasource; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import com.google.android.exoplayer2.source.MediaSource; @@ -28,10 +27,4 @@ public interface ManagedMediaSource extends MediaSource { * @return whether this source is for the specified stream */ boolean isStreamEqual(@NonNull PlayQueueItem stream); - - @Nullable - @Override - default Object getTag() { - return this; - } } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java index ff0cf21fa..4c0380767 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java @@ -8,6 +8,8 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.source.ConcatenatingMediaSource; import com.google.android.exoplayer2.source.ShuffleOrder; +import org.schabi.newpipe.player.mediaitem.MediaItemTag; + public class ManagedMediaSourcePlaylist { @NonNull private final ConcatenatingMediaSource internalSource; @@ -34,8 +36,14 @@ public class ManagedMediaSourcePlaylist { */ @Nullable public ManagedMediaSource get(final int index) { - return (index < 0 || index >= size()) - ? null : (ManagedMediaSource) internalSource.getMediaSource(index).getTag(); + if (index < 0 || index >= size()) { + return null; + } + + return MediaItemTag + .from(internalSource.getMediaSource(index).getMediaItem()) + .flatMap(tag -> tag.getMaybeExtras(ManagedMediaSource.class)) + .orElse(null); } @NonNull @@ -54,7 +62,7 @@ public class ManagedMediaSourcePlaylist { * @see #append(ManagedMediaSource) */ public synchronized void expand() { - append(new PlaceholderMediaSource()); + append(PlaceholderMediaSource.COPY); } /** @@ -115,10 +123,10 @@ public class ManagedMediaSourcePlaylist { public synchronized void invalidate(final int index, @Nullable final Handler handler, @Nullable final Runnable finalizingAction) { - if (get(index) instanceof PlaceholderMediaSource) { + if (get(index) == PlaceholderMediaSource.COPY) { return; } - update(index, new PlaceholderMediaSource(), handler, finalizingAction); + update(index, PlaceholderMediaSource.COPY, handler, finalizingAction); } /** diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java index 1cd855627..92d4403c8 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java @@ -1,28 +1,35 @@ package org.schabi.newpipe.player.mediasource; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.source.BaseMediaSource; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.CompositeMediaSource; import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.upstream.Allocator; -import com.google.android.exoplayer2.upstream.TransferListener; +import org.schabi.newpipe.player.mediaitem.PlaceholderTag; import org.schabi.newpipe.player.playqueue.PlayQueueItem; -public class PlaceholderMediaSource extends BaseMediaSource implements ManagedMediaSource { - /** - * Returns the {@link MediaItem} whose media is provided by the source. - */ +import androidx.annotation.NonNull; + +final class PlaceholderMediaSource + extends CompositeMediaSource implements ManagedMediaSource { + public static final PlaceholderMediaSource COPY = new PlaceholderMediaSource(); + private static final MediaItem MEDIA_ITEM = PlaceholderTag.EMPTY.withExtras(COPY).asMediaItem(); + + private PlaceholderMediaSource() { } + @Override public MediaItem getMediaItem() { - return null; + return MEDIA_ITEM; } - // Do nothing, so this will stall the playback @Override - public void maybeThrowSourceInfoRefreshError() { } + protected void onChildSourceInfoRefreshed(final Void id, + final MediaSource mediaSource, + final Timeline timeline) { + /* Do nothing, no timeline updates or error will stall playback */ + } @Override public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator, @@ -33,12 +40,6 @@ public class PlaceholderMediaSource extends BaseMediaSource implements ManagedMe @Override public void releasePeriod(final MediaPeriod mediaPeriod) { } - @Override - protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) { } - - @Override - protected void releaseSourceInternal() { } - @Override public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity, final boolean isInterruptable) { diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java b/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java deleted file mode 100644 index 389be7062..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java +++ /dev/null @@ -1,92 +0,0 @@ -package org.schabi.newpipe.player.playback; - -import android.content.Context; -import android.text.TextUtils; -import android.util.Pair; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.Format; -import com.google.android.exoplayer2.RendererCapabilities.Capabilities; -import com.google.android.exoplayer2.source.TrackGroup; -import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; -import com.google.android.exoplayer2.trackselection.ExoTrackSelection; -import com.google.android.exoplayer2.util.Assertions; - -/** - * This class allows irregular text language labels for use when selecting text captions and - * is mostly a copy-paste from {@link DefaultTrackSelector}. - *

- * This is a hack and should be removed once ExoPlayer fixes language normalization to accept - * a broader set of languages. - *

- */ -public class CustomTrackSelector extends DefaultTrackSelector { - private String preferredTextLanguage; - - public CustomTrackSelector(final Context context, - final ExoTrackSelection.Factory adaptiveTrackSelectionFactory) { - super(context, adaptiveTrackSelectionFactory); - } - - private static boolean formatHasLanguage(final Format format, final String language) { - return language != null && TextUtils.equals(language, format.language); - } - - public String getPreferredTextLanguage() { - return preferredTextLanguage; - } - - public void setPreferredTextLanguage(@NonNull final String label) { - Assertions.checkNotNull(label); - if (!label.equals(preferredTextLanguage)) { - preferredTextLanguage = label; - invalidate(); - } - } - - @Override - @Nullable - protected Pair selectTextTrack( - final TrackGroupArray groups, - @NonNull final int[][] formatSupport, - @NonNull final Parameters params, - @Nullable final String selectedAudioLanguage) { - TrackGroup selectedGroup = null; - int selectedTrackIndex = C.INDEX_UNSET; - TextTrackScore selectedTrackScore = null; - - for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { - final TrackGroup trackGroup = groups.get(groupIndex); - @Capabilities final int[] trackFormatSupport = formatSupport[groupIndex]; - - for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { - if (isSupported(trackFormatSupport[trackIndex], - params.exceedRendererCapabilitiesIfNecessary)) { - final Format format = trackGroup.getFormat(trackIndex); - final TextTrackScore trackScore = new TextTrackScore(format, params, - trackFormatSupport[trackIndex], selectedAudioLanguage); - - if (formatHasLanguage(format, preferredTextLanguage)) { - selectedGroup = trackGroup; - selectedTrackIndex = trackIndex; - selectedTrackScore = trackScore; - break; // found user selected match (perfect!) - - } else if (trackScore.isWithinConstraints && (selectedTrackScore == null - || trackScore.compareTo(selectedTrackScore) > 0)) { - selectedGroup = trackGroup; - selectedTrackIndex = trackIndex; - selectedTrackScore = trackScore; - } - } - } - } - return selectedGroup == null ? null - : Pair.create(new ExoTrackSelection.Definition(selectedGroup, selectedTrackIndex), - Assertions.checkNotNull(selectedTrackScore)); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java index f3049d11d..9b13bb3d7 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java @@ -11,11 +11,12 @@ import com.google.android.exoplayer2.source.MediaSource; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediasource.FailedMediaSource; import org.schabi.newpipe.player.mediasource.LoadedMediaSource; import org.schabi.newpipe.player.mediasource.ManagedMediaSource; import org.schabi.newpipe.player.mediasource.ManagedMediaSourcePlaylist; -import org.schabi.newpipe.player.mediasource.PlaceholderMediaSource; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.playqueue.events.MoveEvent; @@ -195,7 +196,7 @@ public class MediaSourceManager { //////////////////////////////////////////////////////////////////////////*/ private Subscriber getReactor() { - return new Subscriber() { + return new Subscriber<>() { @Override public void onSubscribe(@NonNull final Subscription d) { playQueueReactor.cancel(); @@ -209,10 +210,12 @@ public class MediaSourceManager { } @Override - public void onError(@NonNull final Throwable e) { } + public void onError(@NonNull final Throwable e) { + } @Override - public void onComplete() { } + public void onComplete() { + } }; } @@ -292,11 +295,11 @@ public class MediaSourceManager { } final ManagedMediaSource mediaSource = playlist.get(playQueue.getIndex()); - if (mediaSource == null) { + final PlayQueueItem playQueueItem = playQueue.getItem(); + if (mediaSource == null || playQueueItem == null) { return false; } - final PlayQueueItem playQueueItem = playQueue.getItem(); return mediaSource.isStreamEqual(playQueueItem); } @@ -315,7 +318,7 @@ public class MediaSourceManager { isBlocked.set(true); } - private void maybeUnblock() { + private boolean maybeUnblock() { if (DEBUG) { Log.d(TAG, "maybeUnblock() called."); } @@ -323,14 +326,17 @@ public class MediaSourceManager { if (isBlocked.get()) { isBlocked.set(false); playbackListener.onPlaybackUnblock(playlist.getParentMediaSource()); + return true; } + + return false; } /*////////////////////////////////////////////////////////////////////////// // Metadata Synchronization //////////////////////////////////////////////////////////////////////////*/ - private void maybeSync() { + private void maybeSync(final boolean wasBlocked) { if (DEBUG) { Log.d(TAG, "maybeSync() called."); } @@ -340,13 +346,13 @@ public class MediaSourceManager { return; } - playbackListener.onPlaybackSynchronize(currentItem); + playbackListener.onPlaybackSynchronize(currentItem, wasBlocked); } private synchronized void maybeSynchronizePlayer() { if (isPlayQueueReady() && isPlaybackReady()) { - maybeUnblock(); - maybeSync(); + final boolean isBlockReleased = maybeUnblock(); + maybeSync(isBlockReleased); } } @@ -417,20 +423,29 @@ public class MediaSourceManager { private Single getLoadedMediaSource(@NonNull final PlayQueueItem stream) { return stream.getStream().map(streamInfo -> { final MediaSource source = playbackListener.sourceOf(stream, streamInfo); - if (source == null) { + if (source == null || !MediaItemTag.from(source.getMediaItem()).isPresent()) { final String message = "Unable to resolve source from stream info. " + "URL: " + stream.getUrl() + ", " + "audio count: " + streamInfo.getAudioStreams().size() + ", " + "video count: " + streamInfo.getVideoOnlyStreams().size() + ", " + streamInfo.getVideoStreams().size(); - return new FailedMediaSource(stream, new MediaSourceResolutionException(message)); + return (ManagedMediaSource) + FailedMediaSource.of(stream, new MediaSourceResolutionException(message)); } + final MediaItemTag tag = MediaItemTag.from(source.getMediaItem()).get(); final long expiration = System.currentTimeMillis() + ServiceHelper.getCacheExpirationMillis(streamInfo.getServiceId()); - return new LoadedMediaSource(source, stream, expiration); - }).onErrorReturn(throwable -> new FailedMediaSource(stream, - new StreamInfoLoadException(throwable))); + return new LoadedMediaSource(source, tag, stream, expiration); + }).onErrorReturn(throwable -> { + if (throwable instanceof ExtractionException) { + return FailedMediaSource.of(stream, new StreamInfoLoadException(throwable)); + } + // Non-source related error expected here (e.g. network), + // should allow retry shortly after the error. + return FailedMediaSource.of(stream, new Exception(throwable), + /*allowRetryIn=*/TimeUnit.MILLISECONDS.convert(3, TimeUnit.SECONDS)); + }); } private void onMediaSourceReceived(@NonNull final PlayQueueItem item, @@ -478,23 +493,23 @@ public class MediaSourceManager { /** * Checks if the current playing index contains an expired {@link ManagedMediaSource}. - * If so, the expired source is replaced by a {@link PlaceholderMediaSource} and + * If so, the expired source is replaced by a dummy {@link ManagedMediaSource} and * {@link #loadImmediate()} is called to reload the current item. *

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

- * Under both cases, {@link #maybeSync()} will be called to ensure the listener + * Under both cases, {@link #maybeSync(boolean)} will be called to ensure the listener * is up-to-date. */ private void maybeRenewCurrentIndex() { final int currentIndex = playQueue.getIndex(); + final PlayQueueItem currentItem = playQueue.getItem(); final ManagedMediaSource currentSource = playlist.get(currentIndex); - if (currentSource == null) { + if (currentItem == null || currentSource == null) { return; } - final PlayQueueItem currentItem = playQueue.getItem(); if (!currentSource.shouldBeReplacedWith(currentItem, true)) { maybeSynchronizePlayer(); return; diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java b/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java index 811f82b3b..737607001 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java @@ -51,9 +51,10 @@ public interface PlaybackListener { * May be called anytime at any amount once unblock is called. *

* - * @param item + * @param item item the player should be playing/synchronized to + * @param wasBlocked was the player recently released from blocking state */ - void onPlaybackSynchronize(@NonNull PlayQueueItem item); + void onPlaybackSynchronize(@NonNull PlayQueueItem item, boolean wasBlocked); /** * Requests the listener to resolve a stream info into a media source diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/PlayerMediaSession.java b/app/src/main/java/org/schabi/newpipe/player/playback/PlayerMediaSession.java index 9dcb12344..ee0a6f118 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/PlayerMediaSession.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/PlayerMediaSession.java @@ -88,6 +88,8 @@ public class PlayerMediaSession implements MediaSessionCallback { @Override public void play() { player.play(); + // hide the player controls even if the play command came from the media session + player.hideControls(0, 0); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java b/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java index 0814092fa..5d67e6967 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java @@ -3,7 +3,7 @@ package org.schabi.newpipe.player.playback; import android.content.Context; import android.view.SurfaceHolder; -import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.video.DummySurface; /** @@ -25,10 +25,10 @@ import com.google.android.exoplayer2.video.DummySurface; public final class SurfaceHolderCallback implements SurfaceHolder.Callback { private final Context context; - private final SimpleExoPlayer player; + private final Player player; private DummySurface dummySurface; - public SurfaceHolderCallback(final Context context, final SimpleExoPlayer player) { + public SurfaceHolderCallback(final Context context, final Player player) { this.context = context; this.player = player; } diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java index 07c8d9f90..df2747c3b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java @@ -4,20 +4,19 @@ import android.util.Log; import androidx.annotation.NonNull; -import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; +import java.util.stream.Collectors; import io.reactivex.rxjava3.core.SingleObserver; import io.reactivex.rxjava3.disposables.Disposable; -abstract class AbstractInfoPlayQueue extends PlayQueue { +abstract class AbstractInfoPlayQueue> + extends PlayQueue { boolean isInitial; private boolean isComplete; @@ -27,12 +26,15 @@ abstract class AbstractInfoPlayQueue ext private transient Disposable fetchReactor; - AbstractInfoPlayQueue(final U item) { - this(item.getServiceId(), item.getUrl(), null, Collections.emptyList(), 0); + protected AbstractInfoPlayQueue(final T info) { + this(info.getServiceId(), info.getUrl(), info.getNextPage(), info.getRelatedItems(), 0); } - AbstractInfoPlayQueue(final int serviceId, final String url, final Page nextPage, - final List streams, final int index) { + protected AbstractInfoPlayQueue(final int serviceId, + final String url, + final Page nextPage, + final List streams, + final int index) { super(index, extractListItems(streams)); this.baseUrl = url; @@ -51,7 +53,7 @@ abstract class AbstractInfoPlayQueue ext } SingleObserver getHeadListObserver() { - return new SingleObserver() { + return new SingleObserver<>() { @Override public void onSubscribe(@NonNull final Disposable d) { if (isComplete || !isInitial || (fetchReactor != null @@ -85,8 +87,8 @@ abstract class AbstractInfoPlayQueue ext }; } - SingleObserver getNextPageObserver() { - return new SingleObserver() { + SingleObserver> getNextPageObserver() { + return new SingleObserver<>() { @Override public void onSubscribe(@NonNull final Disposable d) { if (isComplete || isInitial || (fetchReactor != null @@ -98,7 +100,8 @@ abstract class AbstractInfoPlayQueue ext } @Override - public void onSuccess(@NonNull final ListExtractor.InfoItemsPage result) { + public void onSuccess( + @NonNull final ListExtractor.InfoItemsPage result) { if (!result.hasNextPage()) { isComplete = true; } @@ -129,12 +132,6 @@ abstract class AbstractInfoPlayQueue ext } private static List extractListItems(final List infoItems) { - final List result = new ArrayList<>(); - for (final InfoItem stream : infoItems) { - if (stream instanceof StreamInfoItem) { - result.add(new PlayQueueItem((StreamInfoItem) stream)); - } - } - return result; + return infoItems.stream().map(PlayQueueItem::new).collect(Collectors.toList()); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelPlayQueue.java index f85349797..1e1fef85e 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelPlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelPlayQueue.java @@ -3,7 +3,6 @@ package org.schabi.newpipe.player.playqueue; import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.channel.ChannelInfo; -import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.util.ExtractorHelper; @@ -12,13 +11,10 @@ import java.util.List; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.schedulers.Schedulers; -public final class ChannelPlayQueue extends AbstractInfoPlayQueue { - public ChannelPlayQueue(final ChannelInfoItem item) { - super(item); - } +public final class ChannelPlayQueue extends AbstractInfoPlayQueue { public ChannelPlayQueue(final ChannelInfo info) { - this(info.getServiceId(), info.getUrl(), info.getNextPage(), info.getRelatedItems(), 0); + super(info); } public ChannelPlayQueue(final int serviceId, diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java index f2259b120..f46c9d72f 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java @@ -528,7 +528,19 @@ public abstract class PlayQueue implements Serializable { return false; } final PlayQueue other = (PlayQueue) obj; - return streams.equals(other.streams); + if (size() != other.size()) { + return false; + } + for (int i = 0; i < size(); i++) { + final PlayQueueItem stream = streams.get(i); + final PlayQueueItem otherStream = other.streams.get(i); + // Check is based on serviceId and URL + if (stream.getServiceId() != otherStream.getServiceId() + || !stream.getUrl().equals(otherStream.getUrl())) { + return false; + } + } + return true; } @Override diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java index f7dfc562e..bf31ea9b1 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java @@ -52,6 +52,7 @@ public class PlayQueueItem implements Serializable { item.getUploaderUrl(), item.getStreamType()); } + @SuppressWarnings("ParameterNumber") private PlayQueueItem(@Nullable final String name, @Nullable final String url, final int serviceId, final long duration, @Nullable final String thumbnailUrl, @Nullable final String uploader, diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java index ac5dce9ba..01883d7d9 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java @@ -2,7 +2,6 @@ package org.schabi.newpipe.player.playqueue; import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; -import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.util.ExtractorHelper; @@ -11,13 +10,10 @@ import java.util.List; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.schedulers.Schedulers; -public final class PlaylistPlayQueue extends AbstractInfoPlayQueue { - public PlaylistPlayQueue(final PlaylistInfoItem item) { - super(item); - } +public final class PlaylistPlayQueue extends AbstractInfoPlayQueue { public PlaylistPlayQueue(final PlaylistInfo info) { - this(info.getServiceId(), info.getUrl(), info.getNextPage(), info.getRelatedItems(), 0); + super(info); } public PlaylistPlayQueue(final int serviceId, diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java index 29be402c5..9bded9331 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java @@ -12,6 +12,8 @@ import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.helper.PlayerHelper; +import org.schabi.newpipe.player.mediaitem.MediaItemTag; +import org.schabi.newpipe.player.mediaitem.StreamInfoTag; import org.schabi.newpipe.util.ListHelper; public class AudioPlaybackResolver implements PlaybackResolver { @@ -40,7 +42,7 @@ public class AudioPlaybackResolver implements PlaybackResolver { } final AudioStream audio = info.getAudioStreams().get(index); - final MediaSourceTag tag = new MediaSourceTag(info); + final MediaItemTag tag = StreamInfoTag.of(info); return buildMediaSource(dataSource, audio.getUrl(), PlayerHelper.cacheKeyOf(info, audio), MediaFormat.getSuffixById(audio.getFormatId()), tag); } diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/MediaSourceTag.java b/app/src/main/java/org/schabi/newpipe/player/resolver/MediaSourceTag.java deleted file mode 100644 index 360e92e7f..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/MediaSourceTag.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.schabi.newpipe.player.resolver; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.VideoStream; - -import java.io.Serializable; -import java.util.Collections; -import java.util.List; - -public class MediaSourceTag implements Serializable { - @NonNull - private final StreamInfo metadata; - - @NonNull - private final List sortedAvailableVideoStreams; - private final int selectedVideoStreamIndex; - - public MediaSourceTag(@NonNull final StreamInfo metadata, - @NonNull final List sortedAvailableVideoStreams, - final int selectedVideoStreamIndex) { - this.metadata = metadata; - this.sortedAvailableVideoStreams = sortedAvailableVideoStreams; - this.selectedVideoStreamIndex = selectedVideoStreamIndex; - } - - public MediaSourceTag(@NonNull final StreamInfo metadata) { - this(metadata, Collections.emptyList(), /*indexNotAvailable=*/-1); - } - - @NonNull - public StreamInfo getMetadata() { - return metadata; - } - - @NonNull - public List getSortedAvailableVideoStreams() { - return sortedAvailableVideoStreams; - } - - public int getSelectedVideoStreamIndex() { - return selectedVideoStreamIndex; - } - - @Nullable - public VideoStream getSelectedVideoStream() { - return selectedVideoStreamIndex < 0 - || selectedVideoStreamIndex >= sortedAvailableVideoStreams.size() - ? null : sortedAvailableVideoStreams.get(selectedVideoStreamIndex); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java index cfe9dbb62..90b38ed51 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java @@ -3,20 +3,23 @@ package org.schabi.newpipe.player.resolver; import android.net.Uri; import android.text.TextUtils; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.MediaSourceFactory; import com.google.android.exoplayer2.util.Util; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.player.helper.PlayerDataSource; +import org.schabi.newpipe.player.mediaitem.MediaItemTag; +import org.schabi.newpipe.player.mediaitem.StreamInfoTag; import org.schabi.newpipe.util.StreamTypeUtil; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import static org.schabi.newpipe.player.helper.PlayerDataSource.LIVE_STREAM_EDGE_GAP_MILLIS; + public interface PlaybackResolver extends Resolver { @Nullable @@ -27,7 +30,7 @@ public interface PlaybackResolver extends Resolver { return null; } - final MediaSourceTag tag = new MediaSourceTag(info); + final StreamInfoTag tag = StreamInfoTag.of(info); if (!info.getHlsUrl().isEmpty()) { return buildLiveMediaSource(dataSource, info.getHlsUrl(), C.TYPE_HLS, tag); } else if (!info.getDashMpdUrl().isEmpty()) { @@ -41,8 +44,8 @@ public interface PlaybackResolver extends Resolver { default MediaSource buildLiveMediaSource(@NonNull final PlayerDataSource dataSource, @NonNull final String sourceUrl, @C.ContentType final int type, - @NonNull final MediaSourceTag metadata) { - final MediaSourceFactory factory; + @NonNull final MediaItemTag metadata) { + final MediaSource.Factory factory; switch (type) { case C.TYPE_SS: factory = dataSource.getLiveSsMediaSourceFactory(); @@ -61,7 +64,11 @@ public interface PlaybackResolver extends Resolver { new MediaItem.Builder() .setTag(metadata) .setUri(Uri.parse(sourceUrl)) - .setLiveTargetOffsetMs(PlayerDataSource.LIVE_STREAM_EDGE_GAP_MILLIS) + .setLiveConfiguration( + new MediaItem.LiveConfiguration.Builder() + .setTargetOffsetMs(LIVE_STREAM_EDGE_GAP_MILLIS) + .build() + ) .build() ); } @@ -71,12 +78,12 @@ public interface PlaybackResolver extends Resolver { @NonNull final String sourceUrl, @NonNull final String cacheKey, @NonNull final String overrideExtension, - @NonNull final MediaSourceTag metadata) { + @NonNull final MediaItemTag metadata) { final Uri uri = Uri.parse(sourceUrl); @C.ContentType final int type = TextUtils.isEmpty(overrideExtension) ? Util.inferContentType(uri) : Util.inferContentType("." + overrideExtension); - final MediaSourceFactory factory; + final MediaSource.Factory factory; switch (type) { case C.TYPE_SS: factory = dataSource.getLiveSsMediaSourceFactory(); diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java index 245a85e71..1aa7a5a18 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java @@ -6,6 +6,7 @@ import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MergingMediaSource; @@ -17,10 +18,13 @@ import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.helper.PlayerHelper; +import org.schabi.newpipe.player.mediaitem.MediaItemTag; +import org.schabi.newpipe.player.mediaitem.StreamInfoTag; import org.schabi.newpipe.util.ListHelper; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import static com.google.android.exoplayer2.C.TIME_UNSET; @@ -31,10 +35,17 @@ public class VideoPlaybackResolver implements PlaybackResolver { private final PlayerDataSource dataSource; @NonNull private final QualityResolver qualityResolver; + private SourceType streamSourceType; @Nullable private String playbackQuality; + public enum SourceType { + LIVE_STREAM, + VIDEO_WITH_SEPARATED_AUDIO, + VIDEO_WITH_AUDIO_OR_AUDIO_ONLY + } + public VideoPlaybackResolver(@NonNull final Context context, @NonNull final PlayerDataSource dataSource, @NonNull final QualityResolver qualityResolver) { @@ -48,6 +59,7 @@ public class VideoPlaybackResolver implements PlaybackResolver { public MediaSource resolve(@NonNull final StreamInfo info) { final MediaSource liveSource = maybeBuildLiveMediaSource(dataSource, info); if (liveSource != null) { + streamSourceType = SourceType.LIVE_STREAM; return liveSource; } @@ -55,7 +67,7 @@ public class VideoPlaybackResolver implements PlaybackResolver { // Create video stream source final List videos = ListHelper.getSortedStreamVideosList(context, - info.getVideoStreams(), info.getVideoOnlyStreams(), false); + info.getVideoStreams(), info.getVideoOnlyStreams(), false, true); final int index; if (videos.isEmpty()) { index = -1; @@ -64,8 +76,10 @@ public class VideoPlaybackResolver implements PlaybackResolver { } else { index = qualityResolver.getOverrideResolutionIndex(videos, getPlaybackQuality()); } - final MediaSourceTag tag = new MediaSourceTag(info, videos, index); - @Nullable final VideoStream video = tag.getSelectedVideoStream(); + final MediaItemTag tag = StreamInfoTag.of(info, videos, index); + @Nullable final VideoStream video = tag.getMaybeQuality() + .map(MediaItemTag.Quality::getSelectedVideoStream) + .orElse(null); if (video != null) { final MediaSource streamSource = buildMediaSource(dataSource, video.getUrl(), @@ -85,6 +99,9 @@ public class VideoPlaybackResolver implements PlaybackResolver { PlayerHelper.cacheKeyOf(info, audio), MediaFormat.getSuffixById(audio.getFormatId()), tag); mediaSources.add(audioSource); + streamSourceType = SourceType.VIDEO_WITH_SEPARATED_AUDIO; + } else { + streamSourceType = SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY; } // If there is no audio or video sources, then this media source cannot be played back @@ -100,12 +117,18 @@ public class VideoPlaybackResolver implements PlaybackResolver { if (mimeType == null) { continue; } - final MediaSource textSource = dataSource.getSampleMediaSourceFactory() - .createMediaSource( - new MediaItem.Subtitle(Uri.parse(subtitle.getUrl()), - mimeType, - PlayerHelper.captionLanguageOf(context, subtitle)), - TIME_UNSET); + final @C.RoleFlags int textRoleFlag = subtitle.isAutoGenerated() + ? C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND + : C.ROLE_FLAG_CAPTION; + final MediaItem.SubtitleConfiguration textMediaItem = + new MediaItem.SubtitleConfiguration.Builder(Uri.parse(subtitle.getUrl())) + .setMimeType(mimeType) + .setRoleFlags(textRoleFlag) + .setLanguage(PlayerHelper.captionLanguageOf(context, subtitle)) + .build(); + final MediaSource textSource = dataSource + .getSampleMediaSourceFactory() + .createMediaSource(textMediaItem, TIME_UNSET); mediaSources.add(textSource); } } @@ -118,6 +141,16 @@ public class VideoPlaybackResolver implements PlaybackResolver { } } + /** + * Returns the last resolved {@link StreamInfo}'s {@link SourceType source type}. + * + * @return {@link Optional#empty()} if nothing was resolved, otherwise the {@link SourceType} + * of the last resolved {@link StreamInfo} inside an {@link Optional} + */ + public Optional getStreamSourceType() { + return Optional.ofNullable(streamSourceType); + } + @Nullable public String getPlaybackQuality() { return playbackQuality; diff --git a/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java index e08562908..70ac5cdcc 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java @@ -50,7 +50,7 @@ public class AppearanceSettingsFragment extends BasePreferenceFragment { @Override public boolean onPreferenceTreeClick(final Preference preference) { - if (preference.getKey().equals(getString(R.string.caption_settings_key))) { + if (getString(R.string.caption_settings_key).equals(preference.getKey())) { try { startActivity(new Intent(Settings.ACTION_CAPTIONING_SETTINGS)); } catch (final ActivityNotFoundException e) { diff --git a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java index 395c7c0f0..dd9f5fb1f 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java @@ -10,6 +10,7 @@ import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.local.feed.notifications.NotificationWorker; import java.util.Optional; @@ -26,6 +27,8 @@ public class DebugSettingsFragment extends BasePreferenceFragment { = findPreference(getString(R.string.show_memory_leaks_key)); final Preference showImageIndicatorsPreference = findPreference(getString(R.string.show_image_indicators_key)); + final Preference checkNewStreamsPreference + = findPreference(getString(R.string.check_new_streams_key)); final Preference crashTheAppPreference = findPreference(getString(R.string.crash_the_app_key)); final Preference showErrorSnackbarPreference @@ -36,6 +39,7 @@ public class DebugSettingsFragment extends BasePreferenceFragment { assert allowHeapDumpingPreference != null; assert showMemoryLeaksPreference != null; assert showImageIndicatorsPreference != null; + assert checkNewStreamsPreference != null; assert crashTheAppPreference != null; assert showErrorSnackbarPreference != null; assert createErrorNotificationPreference != null; @@ -62,6 +66,11 @@ public class DebugSettingsFragment extends BasePreferenceFragment { return true; }); + checkNewStreamsPreference.setOnPreferenceClickListener(preference -> { + NotificationWorker.runNow(preference.getContext()); + return true; + }); + crashTheAppPreference.setOnPreferenceClickListener(preference -> { throw new RuntimeException(DUMMY); }); diff --git a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java index fe327e1b5..ec98b865e 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java @@ -185,7 +185,7 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { } @Override - public boolean onPreferenceTreeClick(final Preference preference) { + public boolean onPreferenceTreeClick(@NonNull final Preference preference) { if (DEBUG) { Log.d(TAG, "onPreferenceTreeClick() called with: " + "preference = [" + preference + "]"); diff --git a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java index d7fb559d6..3776d78f6 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java @@ -7,10 +7,9 @@ import android.view.MenuItem; import androidx.annotation.NonNull; -import org.schabi.newpipe.App; -import org.schabi.newpipe.CheckForNewAppVersion; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; +import org.schabi.newpipe.util.ReleaseVersionUtil; public class MainSettingsFragment extends BasePreferenceFragment { public static final boolean DEBUG = MainActivity.DEBUG; @@ -24,7 +23,7 @@ public class MainSettingsFragment extends BasePreferenceFragment { setHasOptionsMenu(true); // Otherwise onCreateOptionsMenu is not called // Check if the app is updatable - if (!CheckForNewAppVersion.isReleaseApk(App.getApp())) { + if (!ReleaseVersionUtil.isReleaseApk()) { getPreferenceScreen().removePreference( findPreference(getString(R.string.update_pref_screen_key))); diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java index aa21c4422..1e1d08856 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java +++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java @@ -70,7 +70,7 @@ public final class NewPipeSettings { PreferenceManager.setDefaultValues(context, R.xml.appearance_settings, true); PreferenceManager.setDefaultValues(context, R.xml.history_settings, true); PreferenceManager.setDefaultValues(context, R.xml.content_settings, true); - PreferenceManager.setDefaultValues(context, R.xml.notification_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.player_notification_settings, true); PreferenceManager.setDefaultValues(context, R.xml.update_settings, true); PreferenceManager.setDefaultValues(context, R.xml.debug_settings, true); diff --git a/app/src/main/java/org/schabi/newpipe/settings/NotificationsSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/NotificationsSettingsFragment.kt new file mode 100644 index 000000000..fcc9abf73 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/NotificationsSettingsFragment.kt @@ -0,0 +1,124 @@ +package org.schabi.newpipe.settings + +import android.content.SharedPreferences +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +import android.graphics.Color +import android.os.Bundle +import androidx.preference.Preference +import com.google.android.material.snackbar.Snackbar +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.Disposable +import org.schabi.newpipe.R +import org.schabi.newpipe.database.subscription.NotificationMode +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.ErrorUtil +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.local.feed.notifications.NotificationHelper +import org.schabi.newpipe.local.feed.notifications.NotificationWorker +import org.schabi.newpipe.local.feed.notifications.ScheduleOptions +import org.schabi.newpipe.local.subscription.SubscriptionManager + +class NotificationsSettingsFragment : BasePreferenceFragment(), OnSharedPreferenceChangeListener { + + private var notificationWarningSnackbar: Snackbar? = null + private var loader: Disposable? = null + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.notifications_settings) + + // main check is done in onResume, but also do it here to prevent flickering + preferenceScreen.isEnabled = + NotificationHelper.areNotificationsEnabledOnDevice(requireContext()) + } + + override fun onStart() { + super.onStart() + defaultPreferences.registerOnSharedPreferenceChangeListener(this) + } + + override fun onStop() { + defaultPreferences.unregisterOnSharedPreferenceChangeListener(this) + super.onStop() + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + val context = context ?: return + if (key == getString(R.string.streams_notifications_interval_key) || + key == getString(R.string.streams_notifications_network_key) + ) { + // apply new configuration + NotificationWorker.schedule(context, ScheduleOptions.from(context), true) + } else if (key == getString(R.string.enable_streams_notifications)) { + if (NotificationHelper.areNewStreamsNotificationsEnabled(context)) { + // Start the worker, because notifications were disabled previously. + NotificationWorker.schedule(context) + } else { + // The user disabled the notifications. Cancel the worker to save energy. + // A new one will be created once the notifications are enabled again. + NotificationWorker.cancel(context) + } + } + } + + override fun onResume() { + super.onResume() + + // Check whether the notifications are disabled in the device's app settings. + // If they are disabled, show a snackbar informing the user about that + // while allowing them to open the device's app settings. + val enabled = NotificationHelper.areNotificationsEnabledOnDevice(requireContext()) + preferenceScreen.isEnabled = enabled // it is disabled by default, see the xml + if (!enabled) { + if (notificationWarningSnackbar == null) { + notificationWarningSnackbar = Snackbar.make( + listView, + R.string.notifications_disabled, + Snackbar.LENGTH_INDEFINITE + ).apply { + setAction(R.string.settings) { + NotificationHelper.openNewPipeSystemNotificationSettings(it.context) + } + setActionTextColor(Color.YELLOW) + addCallback(object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar, event: Int) { + super.onDismissed(transientBottomBar, event) + notificationWarningSnackbar = null + } + }) + show() + } + } + } + + // (Re-)Create loader + loader?.dispose() + loader = SubscriptionManager(requireContext()) + .subscriptions() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::updateSubscriptions, this::onError) + } + + override fun onPause() { + loader?.dispose() + loader = null + + notificationWarningSnackbar?.dismiss() + notificationWarningSnackbar = null + + super.onPause() + } + + private fun updateSubscriptions(subscriptions: List) { + val notified = subscriptions.count { it.notificationMode != NotificationMode.DISABLED } + val preference = findPreference(getString(R.string.streams_notifications_channels_key)) + preference?.apply { summary = "$notified/${subscriptions.size}" } + } + + private fun onError(e: Throwable) { + ErrorUtil.showSnackbar( + this, + ErrorInfo(e, UserAction.SUBSCRIPTION_GET, "Get subscriptions list") + ) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java b/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java index 5557efd9c..c7eb0be40 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java @@ -21,7 +21,6 @@ import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.widget.AppCompatImageView; import androidx.fragment.app.Fragment; import androidx.preference.PreferenceManager; @@ -51,8 +50,6 @@ import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; public class PeertubeInstanceListFragment extends Fragment { - private static final int MENU_ITEM_RESTORE_ID = 123456; - private final List instanceList = new ArrayList<>(); private PeertubeInstance selectedInstance; private String savedInstanceListKey; @@ -142,17 +139,12 @@ public class PeertubeInstanceListFragment extends Fragment { public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); - - final MenuItem restoreItem = menu - .add(Menu.NONE, MENU_ITEM_RESTORE_ID, Menu.NONE, R.string.restore_defaults); - restoreItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); - restoreItem.setIcon(AppCompatResources.getDrawable(requireContext(), - R.drawable.ic_settings_backup_restore)); + inflater.inflate(R.menu.menu_chooser_fragment, menu); } @Override public boolean onOptionsItemSelected(final MenuItem item) { - if (item.getItemId() == MENU_ITEM_RESTORE_ID) { + if (item.getItemId() == R.id.menu_item_restore_default) { restoreDefaults(); return true; } @@ -191,7 +183,7 @@ public class PeertubeInstanceListFragment extends Fragment { .setTitle(R.string.restore_defaults) .setMessage(R.string.restore_defaults_confirmation) .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.yes, (dialog, which) -> { + .setPositiveButton(R.string.ok, (dialog, which) -> { sharedPreferences.edit().remove(savedInstanceListKey).apply(); selectInstance(PeertubeInstance.DEFAULT_INSTANCE); updateInstanceList(); diff --git a/app/src/main/java/org/schabi/newpipe/settings/PlayerNotificationSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/PlayerNotificationSettingsFragment.kt new file mode 100644 index 000000000..3549bff42 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/PlayerNotificationSettingsFragment.kt @@ -0,0 +1,19 @@ +package org.schabi.newpipe.settings + +import android.os.Build +import android.os.Bundle +import androidx.preference.Preference +import org.schabi.newpipe.R + +class PlayerNotificationSettingsFragment : BasePreferenceFragment() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResourceRegistry() + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + val colorizePref: Preference? = findPreference(getString(R.string.notification_colorize_key)) + colorizePref?.let { + preferenceScreen.removePreference(it) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java index 116807cbc..0f25be630 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java @@ -5,6 +5,7 @@ import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.ImageView; import android.widget.ProgressBar; import android.widget.TextView; @@ -24,7 +25,6 @@ import org.schabi.newpipe.util.ThemeHelper; import java.util.List; import java.util.Vector; -import de.hdodenhof.circleimageview.CircleImageView; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observer; import io.reactivex.rxjava3.disposables.Disposable; @@ -200,7 +200,7 @@ public class SelectChannelFragment extends DialogFragment { public class SelectChannelItemHolder extends RecyclerView.ViewHolder { public final View view; - final CircleImageView thumbnailView; + final ImageView thumbnailView; final TextView titleView; SelectChannelItemHolder(final View v) { super(v); diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java index 7510bb3bc..3ee6668bf 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java @@ -23,8 +23,6 @@ import androidx.preference.PreferenceFragmentCompat; import com.jakewharton.rxbinding4.widget.RxTextView; -import org.schabi.newpipe.App; -import org.schabi.newpipe.CheckForNewAppVersion; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.SettingsLayoutBinding; @@ -37,6 +35,7 @@ import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchResultListen import org.schabi.newpipe.settings.preferencesearch.PreferenceSearcher; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.KeyboardUtil; +import org.schabi.newpipe.util.ReleaseVersionUtil; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.views.FocusOverlayView; @@ -170,7 +169,7 @@ public class SettingsActivity extends AppCompatActivity implements } @Override - public boolean onPreferenceStartFragment(final PreferenceFragmentCompat caller, + public boolean onPreferenceStartFragment(@NonNull final PreferenceFragmentCompat caller, final Preference preference) { showSettingsFragment(instantiateFragment(preference.getFragment())); return true; @@ -267,7 +266,7 @@ public class SettingsActivity extends AppCompatActivity implements */ private void ensureSearchRepresentsApplicationState() { // Check if the update settings are available - if (!CheckForNewAppVersion.isReleaseApk(App.getApp())) { + if (!ReleaseVersionUtil.isReleaseApk()) { SettingsResourceRegistry.getInstance() .getEntryByPreferencesResId(R.xml.update_settings) .setSearchable(false); diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java index c4751abea..78ddb3786 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java @@ -36,7 +36,8 @@ public final class SettingsResourceRegistry { add(DebugSettingsFragment.class, R.xml.debug_settings).setSearchable(false); add(DownloadSettingsFragment.class, R.xml.download_settings); add(HistorySettingsFragment.class, R.xml.history_settings); - add(NotificationSettingsFragment.class, R.xml.notification_settings); + add(NotificationSettingsFragment.class, R.xml.notifications_settings); + add(PlayerNotificationSettingsFragment.class, R.xml.player_notification_settings); add(UpdateSettingsFragment.class, R.xml.update_settings); add(VideoAudioSettingsFragment.class, R.xml.video_audio_settings); } @@ -115,6 +116,7 @@ public final class SettingsResourceRegistry { return this; } + @NonNull public Class getFragmentClass() { return fragmentClass; } diff --git a/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java index 04bad3815..1043e88c2 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java @@ -1,12 +1,11 @@ package org.schabi.newpipe.settings; -import static org.schabi.newpipe.CheckForNewAppVersion.startNewVersionCheckService; - import android.os.Bundle; import android.widget.Toast; import androidx.preference.Preference; +import org.schabi.newpipe.NewVersionWorker; import org.schabi.newpipe.R; public class UpdateSettingsFragment extends BasePreferenceFragment { @@ -33,7 +32,7 @@ public class UpdateSettingsFragment extends BasePreferenceFragment { // Reset the expire time. This is necessary to check for an update immediately. defaultPreferences.edit() .putLong(getString(R.string.update_expiry_key), 0).apply(); - startNewVersionCheckService(); + NewVersionWorker.enqueueNewVersionCheckingWork(getContext()); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/DurationListPreference.kt b/app/src/main/java/org/schabi/newpipe/settings/custom/DurationListPreference.kt index 14801c01c..f0b89c677 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/custom/DurationListPreference.kt +++ b/app/src/main/java/org/schabi/newpipe/settings/custom/DurationListPreference.kt @@ -14,10 +14,10 @@ import org.schabi.newpipe.util.Localization * If the entry values array have anything other than numbers in it, an exception will be raised. */ class DurationListPreference : ListPreference { - constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) - constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) - constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) - constructor(context: Context?) : super(context) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context) : super(context) override fun onAttached() { super.onAttached() diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java index 045e574be..62455d682 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java +++ b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java @@ -50,7 +50,7 @@ public class NotificationActionsPreference extends Preference { //////////////////////////////////////////////////////////////////////////// @Override - public void onBindViewHolder(final PreferenceViewHolder holder) { + public void onBindViewHolder(@NonNull final PreferenceViewHolder holder) { super.onBindViewHolder(holder); holder.itemView.setClickable(false); diff --git a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt new file mode 100644 index 000000000..6ae264bb5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt @@ -0,0 +1,124 @@ +package org.schabi.newpipe.settings.notifications + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CheckedTextView +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import org.schabi.newpipe.R +import org.schabi.newpipe.database.subscription.NotificationMode +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import org.schabi.newpipe.settings.notifications.NotificationModeConfigAdapter.SubscriptionHolder + +/** + * This [RecyclerView.Adapter] is used in the [NotificationModeConfigFragment]. + * The adapter holds all subscribed channels and their [NotificationMode]s + * and provides the needed data structures and methods for this task. + */ +class NotificationModeConfigAdapter( + private val listener: ModeToggleListener +) : RecyclerView.Adapter() { + + private val differ = AsyncListDiffer(this, DiffCallback()) + + init { + setHasStableIds(true) + } + + override fun onCreateViewHolder(viewGroup: ViewGroup, i: Int): SubscriptionHolder { + val view = LayoutInflater.from(viewGroup.context) + .inflate(R.layout.item_notification_config, viewGroup, false) + return SubscriptionHolder(view, listener) + } + + override fun onBindViewHolder(subscriptionHolder: SubscriptionHolder, i: Int) { + subscriptionHolder.bind(differ.currentList[i]) + } + + fun getItem(position: Int): SubscriptionItem = differ.currentList[position] + + override fun getItemCount() = differ.currentList.size + + override fun getItemId(position: Int): Long { + return differ.currentList[position].id + } + + fun getCurrentList(): List = differ.currentList + + fun update(newData: List) { + differ.submitList( + newData.map { + SubscriptionItem( + id = it.uid, + title = it.name, + notificationMode = it.notificationMode, + serviceId = it.serviceId, + url = it.url + ) + } + ) + } + + data class SubscriptionItem( + val id: Long, + val title: String, + @NotificationMode + val notificationMode: Int, + val serviceId: Int, + val url: String + ) + + class SubscriptionHolder( + itemView: View, + private val listener: ModeToggleListener + ) : RecyclerView.ViewHolder(itemView), View.OnClickListener { + + private val checkedTextView = itemView as CheckedTextView + + init { + itemView.setOnClickListener(this) + } + + fun bind(data: SubscriptionItem) { + checkedTextView.text = data.title + checkedTextView.isChecked = data.notificationMode != NotificationMode.DISABLED + } + + override fun onClick(v: View) { + val mode = if (checkedTextView.isChecked) { + NotificationMode.DISABLED + } else { + NotificationMode.ENABLED + } + listener.onModeChange(bindingAdapterPosition, mode) + } + } + + private class DiffCallback : DiffUtil.ItemCallback() { + + override fun areItemsTheSame(oldItem: SubscriptionItem, newItem: SubscriptionItem): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: SubscriptionItem, newItem: SubscriptionItem): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: SubscriptionItem, newItem: SubscriptionItem): Any? { + if (oldItem.notificationMode != newItem.notificationMode) { + return newItem.notificationMode + } else { + return super.getChangePayload(oldItem, newItem) + } + } + } + + interface ModeToggleListener { + /** + * Triggered when the UI representation of a notification mode is changed. + */ + fun onModeChange(position: Int, @NotificationMode mode: Int) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt new file mode 100644 index 000000000..9021fd68c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt @@ -0,0 +1,119 @@ +package org.schabi.newpipe.settings.notifications + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.RecyclerView +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.R +import org.schabi.newpipe.database.subscription.NotificationMode +import org.schabi.newpipe.local.subscription.SubscriptionManager +import org.schabi.newpipe.settings.notifications.NotificationModeConfigAdapter.ModeToggleListener + +/** + * [NotificationModeConfigFragment] is a settings fragment + * which allows changing the [NotificationMode] of all subscribed channels. + * The [NotificationMode] can either be changed one by one or toggled for all channels. + */ +class NotificationModeConfigFragment : Fragment(), ModeToggleListener { + + private lateinit var updaters: CompositeDisposable + private var loader: Disposable? = null + private var adapter: NotificationModeConfigAdapter? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + updaters = CompositeDisposable() + setHasOptionsMenu(true) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = inflater.inflate(R.layout.fragment_channels_notifications, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val recyclerView: RecyclerView = view.findViewById(R.id.recycler_view) + adapter = NotificationModeConfigAdapter(this) + recyclerView.adapter = adapter + loader?.dispose() + loader = SubscriptionManager(requireContext()) + .subscriptions() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { newData -> adapter?.update(newData) } + } + + override fun onDestroyView() { + loader?.dispose() + loader = null + super.onDestroyView() + } + + override fun onDestroy() { + updaters.dispose() + super.onDestroy() + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.menu_notifications_channels, menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_toggle_all -> { + toggleAll() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + override fun onModeChange(position: Int, @NotificationMode mode: Int) { + // Notification mode has been changed via the UI. + // Now change it in the database. + val subscription = adapter?.getItem(position) ?: return + updaters.add( + SubscriptionManager(requireContext()) + .updateNotificationMode( + subscription.serviceId, + subscription.url, + mode + ) + .subscribeOn(Schedulers.io()) + .subscribe() + ) + } + + private fun toggleAll() { + val subscriptions = adapter?.getCurrentList() ?: return + val mode = subscriptions.firstOrNull()?.notificationMode ?: return + val newMode = when (mode) { + NotificationMode.DISABLED -> NotificationMode.ENABLED + else -> NotificationMode.DISABLED + } + val subscriptionManager = SubscriptionManager(requireContext()) + updaters.add( + CompositeDisposable( + subscriptions.map { item -> + subscriptionManager.updateNotificationMode( + serviceId = item.serviceId, + url = item.url, + mode = newMode + ).subscribeOn(Schedulers.io()) + .subscribe() + } + ) + ) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java index 5835dcab5..a445ea309 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java @@ -4,6 +4,7 @@ import androidx.preference.PreferenceCategory; import androidx.preference.PreferenceScreen; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.stream.Stream; @@ -11,7 +12,7 @@ import java.util.stream.Stream; public class PreferenceSearchConfiguration { private PreferenceSearchFunction searcher = new PreferenceFuzzySearchFunction(); - private final List parserIgnoreElements = Arrays.asList( + private final List parserIgnoreElements = Collections.singletonList( PreferenceCategory.class.getSimpleName()); private final List parserContainerElements = Arrays.asList( PreferenceCategory.class.getSimpleName(), diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.java index 52935ef8e..98d2a5d84 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.java +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.java @@ -58,22 +58,27 @@ public class PreferenceSearchItem { this.searchIndexItemResId = searchIndexItemResId; } + @NonNull public String getKey() { return key; } + @NonNull public String getTitle() { return title; } + @NonNull public String getSummary() { return summary; } + @NonNull public String getEntries() { return entries; } + @NonNull public String getBreadcrumbs() { return breadcrumbs; } @@ -94,7 +99,7 @@ public class PreferenceSearchItem { getBreadcrumbs()); } - + @NonNull @Override public String toString() { return "PreferenceItem: " + title + " " + summary + " " + key; diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java index 490e299bd..73aec4a7b 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java @@ -7,7 +7,6 @@ import android.os.Bundle; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; -import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; @@ -17,7 +16,6 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.widget.AppCompatImageView; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.ItemTouchHelper; @@ -107,12 +105,8 @@ public class ChooseTabsFragment extends Fragment { public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); - - final MenuItem restoreItem = menu.add(R.string.restore_defaults); - restoreItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); - restoreItem.setIcon(AppCompatResources.getDrawable(requireContext(), - R.drawable.ic_settings_backup_restore)); - restoreItem.setOnMenuItemClickListener(ev -> { + inflater.inflate(R.menu.menu_chooser_fragment, menu); + menu.findItem(R.id.menu_item_restore_default).setOnMenuItemClickListener(item -> { restoreDefaults(); return true; }); @@ -136,7 +130,7 @@ public class ChooseTabsFragment extends Fragment { .setTitle(R.string.restore_defaults) .setMessage(R.string.restore_defaults_confirmation) .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.yes, (dialog, which) -> { + .setPositiveButton(R.string.ok, (dialog, which) -> { tabsManager.resetTabs(); updateTabList(); selectedTabsAdapter.notifyDataSetChanged(); diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java index aa03bbfa6..6b1d70a86 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java @@ -8,7 +8,7 @@ import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import com.grack.nanojson.JsonObject; -import com.grack.nanojson.JsonSink; +import com.grack.nanojson.JsonStringWriter; import org.schabi.newpipe.R; import org.schabi.newpipe.database.LocalItem.LocalItemType; @@ -132,7 +132,7 @@ public abstract class Tab { // JSON Handling //////////////////////////////////////////////////////////////////////////*/ - public void writeJsonOn(final JsonSink jsonSink) { + public void writeJsonOn(final JsonStringWriter jsonSink) { jsonSink.object(); jsonSink.value(JSON_TAB_ID_KEY, getTabId()); @@ -141,7 +141,7 @@ public abstract class Tab { jsonSink.end(); } - protected void writeDataToJson(final JsonSink writerSink) { + protected void writeDataToJson(final JsonStringWriter writerSink) { // No-op } @@ -340,7 +340,7 @@ public abstract class Tab { @DrawableRes @Override public int getTabIconRes(final Context context) { - final int kioskIcon = KioskTranslator.getKioskIcon(kioskId, context); + final int kioskIcon = KioskTranslator.getKioskIcon(kioskId); if (kioskIcon <= 0) { throw new IllegalStateException("Kiosk ID is not valid: \"" + kioskId + "\""); @@ -355,7 +355,7 @@ public abstract class Tab { } @Override - protected void writeDataToJson(final JsonSink writerSink) { + protected void writeDataToJson(final JsonStringWriter writerSink) { writerSink.value(JSON_KIOSK_SERVICE_ID_KEY, kioskServiceId) .value(JSON_KIOSK_ID_KEY, kioskId); } @@ -437,7 +437,7 @@ public abstract class Tab { } @Override - protected void writeDataToJson(final JsonSink writerSink) { + protected void writeDataToJson(final JsonStringWriter writerSink) { writerSink.value(JSON_CHANNEL_SERVICE_ID_KEY, channelServiceId) .value(JSON_CHANNEL_URL_KEY, channelUrl) .value(JSON_CHANNEL_NAME_KEY, channelName); @@ -496,7 +496,7 @@ public abstract class Tab { @DrawableRes @Override public int getTabIconRes(final Context context) { - return KioskTranslator.getKioskIcon(getDefaultKioskId(context), context); + return KioskTranslator.getKioskIcon(getDefaultKioskId(context)); } @Override @@ -584,7 +584,7 @@ public abstract class Tab { } @Override - protected void writeDataToJson(final JsonSink writerSink) { + protected void writeDataToJson(final JsonStringWriter writerSink) { writerSink.value(JSON_PLAYLIST_SERVICE_ID_KEY, playlistServiceId) .value(JSON_PLAYLIST_URL_KEY, playlistUrl) .value(JSON_PLAYLIST_NAME_KEY, playlistName) diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java index ca3da9d24..889cc85e6 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java @@ -142,6 +142,7 @@ public class Mp4FromDashWriter { outStream = null; } + @SuppressWarnings("MethodLength") public void build(final SharpStream output) throws IOException { if (done) { throw new RuntimeException("already done"); diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java index ebae3812c..2b69f23ac 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java @@ -121,6 +121,7 @@ public class WebMWriter implements Closeable { clustersOffsetsSizes = null; } + @SuppressWarnings("MethodLength") public void build(final SharpStream out) throws IOException, RuntimeException { if (!out.canRewind()) { throw new IOException("The output stream must be allow seek"); diff --git a/app/src/main/java/org/schabi/newpipe/util/BitmapUtils.java b/app/src/main/java/org/schabi/newpipe/util/BitmapUtils.java deleted file mode 100644 index b6f1eaf49..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/BitmapUtils.java +++ /dev/null @@ -1,44 +0,0 @@ -package org.schabi.newpipe.util; - -import android.graphics.Bitmap; - -import androidx.annotation.Nullable; - -public final class BitmapUtils { - private BitmapUtils() { } - - @Nullable - public static Bitmap centerCrop(final Bitmap inputBitmap, final int newWidth, - final int newHeight) { - if (inputBitmap == null || inputBitmap.isRecycled()) { - return null; - } - - final float sourceWidth = inputBitmap.getWidth(); - final float sourceHeight = inputBitmap.getHeight(); - - final float xScale = newWidth / sourceWidth; - final float yScale = newHeight / sourceHeight; - - final float newXScale; - final float newYScale; - - if (yScale > xScale) { - newXScale = xScale / yScale; - newYScale = 1.0f; - } else { - newXScale = 1.0f; - newYScale = yScale / xScale; - } - - final float scaledWidth = newXScale * sourceWidth; - final float scaledHeight = newYScale * sourceHeight; - - final int left = (int) ((sourceWidth - scaledWidth) / 2); - final int top = (int) ((sourceHeight - scaledHeight) / 2); - final int width = (int) scaledWidth; - final int height = (int) scaledHeight; - - return Bitmap.createBitmap(inputBitmap, left, top, width, height); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java index af94e3366..27009efd1 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -19,6 +19,8 @@ package org.schabi.newpipe.util; +import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; + import android.content.Context; import android.util.Log; import android.view.View; @@ -30,7 +32,6 @@ import androidx.preference.PreferenceManager; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; -import org.schabi.newpipe.util.external_communication.TextLinkifier; import org.schabi.newpipe.extractor.Info; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage; @@ -41,6 +42,7 @@ import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.comments.CommentsInfo; +import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.feed.FeedExtractor; import org.schabi.newpipe.extractor.feed.FeedInfo; import org.schabi.newpipe.extractor.kiosk.KioskInfo; @@ -49,6 +51,7 @@ import org.schabi.newpipe.extractor.search.SearchInfo; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor; +import org.schabi.newpipe.util.external_communication.TextLinkifier; import java.util.Collections; import java.util.List; @@ -57,8 +60,6 @@ import io.reactivex.rxjava3.core.Maybe; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; -import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; - public final class ExtractorHelper { private static final String TAG = ExtractorHelper.class.getSimpleName(); private static final InfoCache CACHE = InfoCache.getInstance(); @@ -84,11 +85,12 @@ public final class ExtractorHelper { .fromQuery(searchString, contentFilter, sortFilter))); } - public static Single getMoreSearchItems(final int serviceId, - final String searchString, - final List contentFilter, - final String sortFilter, - final Page page) { + public static Single> getMoreSearchItems( + final int serviceId, + final String searchString, + final List contentFilter, + final String sortFilter, + final Page page) { checkServiceId(serviceId); return Single.fromCallable(() -> SearchInfo.getMoreItems(NewPipe.getService(serviceId), @@ -124,8 +126,9 @@ public final class ExtractorHelper { ChannelInfo.getInfo(NewPipe.getService(serviceId), url))); } - public static Single getMoreChannelItems(final int serviceId, final String url, - final Page nextPage) { + public static Single> getMoreChannelItems(final int serviceId, + final String url, + final Page nextPage) { checkServiceId(serviceId); return Single.fromCallable(() -> ChannelInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage)); @@ -155,15 +158,17 @@ public final class ExtractorHelper { CommentsInfo.getInfo(NewPipe.getService(serviceId), url))); } - public static Single getMoreCommentItems(final int serviceId, - final CommentsInfo info, - final Page nextPage) { + public static Single> getMoreCommentItems( + final int serviceId, + final CommentsInfo info, + final Page nextPage) { checkServiceId(serviceId); return Single.fromCallable(() -> CommentsInfo.getMoreItems(NewPipe.getService(serviceId), info, nextPage)); } - public static Single getPlaylistInfo(final int serviceId, final String url, + public static Single getPlaylistInfo(final int serviceId, + final String url, final boolean forceLoad) { checkServiceId(serviceId); return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.PLAYLIST, @@ -171,8 +176,9 @@ public final class ExtractorHelper { PlaylistInfo.getInfo(NewPipe.getService(serviceId), url))); } - public static Single getMorePlaylistItems(final int serviceId, final String url, - final Page nextPage) { + public static Single> getMorePlaylistItems(final int serviceId, + final String url, + final Page nextPage) { checkServiceId(serviceId); return Single.fromCallable(() -> PlaylistInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage)); @@ -184,8 +190,9 @@ public final class ExtractorHelper { Single.fromCallable(() -> KioskInfo.getInfo(NewPipe.getService(serviceId), url))); } - public static Single getMoreKioskItems(final int serviceId, final String url, - final Page nextPage) { + public static Single> getMoreKioskItems(final int serviceId, + final String url, + final Page nextPage) { return Single.fromCallable(() -> KioskInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage)); } diff --git a/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java b/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java index f77aa0fda..b8c2ff236 100644 --- a/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java +++ b/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java @@ -57,7 +57,7 @@ public final class KioskTranslator { } } - public static int getKioskIcon(final String kioskId, final Context c) { + public static int getKioskIcon(final String kioskId) { switch (kioskId) { case "Trending": case "Top 50": diff --git a/app/src/main/java/org/schabi/newpipe/util/LayoutManagerSmoothScroller.java b/app/src/main/java/org/schabi/newpipe/util/LayoutManagerSmoothScroller.java deleted file mode 100644 index fd50d2edb..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/LayoutManagerSmoothScroller.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.schabi.newpipe.util; - -import android.content.Context; -import android.graphics.PointF; - -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.LinearSmoothScroller; -import androidx.recyclerview.widget.RecyclerView; - -public class LayoutManagerSmoothScroller extends LinearLayoutManager { - public LayoutManagerSmoothScroller(final Context context) { - super(context, VERTICAL, false); - } - - public LayoutManagerSmoothScroller(final Context context, final int orientation, - final boolean reverseLayout) { - super(context, orientation, reverseLayout); - } - - @Override - public void smoothScrollToPosition(final RecyclerView recyclerView, - final RecyclerView.State state, final int position) { - final RecyclerView.SmoothScroller smoothScroller - = new TopSnappedSmoothScroller(recyclerView.getContext()); - smoothScroller.setTargetPosition(position); - startSmoothScroll(smoothScroller); - } - - private class TopSnappedSmoothScroller extends LinearSmoothScroller { - TopSnappedSmoothScroller(final Context context) { - super(context); - - } - - @Override - public PointF computeScrollVectorForPosition(final int targetPosition) { - return LayoutManagerSmoothScroller.this - .computeScrollVectorForPosition(targetPosition); - } - - @Override - protected int getVerticalSnapPreference() { - return SNAP_TO_START; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java index eb3c21827..c3ccef87c 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java @@ -4,6 +4,7 @@ import android.content.Context; import android.content.SharedPreferences; import android.net.ConnectivityManager; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.core.content.ContextCompat; @@ -19,7 +20,11 @@ import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; public final class ListHelper { // Video format in order of quality. 0=lowest quality, n=highest quality @@ -33,8 +38,9 @@ public final class ListHelper { private static final List AUDIO_FORMAT_EFFICIENCY_RANKING = Arrays.asList(MediaFormat.WEBMA, MediaFormat.M4A, MediaFormat.MP3); - private static final List HIGH_RESOLUTION_LIST - = Arrays.asList("1440p", "2160p", "1440p60", "2160p60"); + private static final Set HIGH_RESOLUTION_LIST + // Uses a HashSet for better performance + = new HashSet<>(Arrays.asList("1440p", "2160p", "1440p60", "2160p60")); private ListHelper() { } @@ -108,17 +114,21 @@ public final class ListHelper { * Join the two lists of video streams (video_only and normal videos), * and sort them according with default format chosen by the user. * - * @param context context to search for the format to give preference - * @param videoStreams normal videos list - * @param videoOnlyStreams video only stream list - * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest + * @param context the context to search for the format to give preference + * @param videoStreams the normal videos list + * @param videoOnlyStreams the video-only stream list + * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest + * @param preferVideoOnlyStreams if video-only streams should preferred when both video-only + * streams and normal video streams are available * @return the sorted list */ - public static List getSortedStreamVideosList(final Context context, - final List videoStreams, - final List - videoOnlyStreams, - final boolean ascendingOrder) { + @NonNull + public static List getSortedStreamVideosList( + @NonNull final Context context, + @Nullable final List videoStreams, + @Nullable final List videoOnlyStreams, + final boolean ascendingOrder, + final boolean preferVideoOnlyStreams) { final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); @@ -128,7 +138,7 @@ public final class ListHelper { R.string.default_video_format_key, R.string.default_video_format_value); return getSortedStreamVideosList(defaultFormat, showHigherResolutions, videoStreams, - videoOnlyStreams, ascendingOrder); + videoOnlyStreams, ascendingOrder, preferVideoOnlyStreams); } /*////////////////////////////////////////////////////////////////////////// @@ -192,56 +202,55 @@ public final class ListHelper { * Join the two lists of video streams (video_only and normal videos), * and sort them according with default format chosen by the user. * - * @param defaultFormat format to give preference - * @param showHigherResolutions show >1080p resolutions - * @param videoStreams normal videos list - * @param videoOnlyStreams video only stream list - * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest + * @param defaultFormat format to give preference + * @param showHigherResolutions show >1080p resolutions + * @param videoStreams normal videos list + * @param videoOnlyStreams video only stream list + * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest + * @param preferVideoOnlyStreams if video-only streams should preferred when both video-only + * streams and normal video streams are available * @return the sorted list */ - static List getSortedStreamVideosList(final MediaFormat defaultFormat, - final boolean showHigherResolutions, - final List videoStreams, - final List videoOnlyStreams, - final boolean ascendingOrder) { - final ArrayList retList = new ArrayList<>(); + @NonNull + static List getSortedStreamVideosList( + @Nullable final MediaFormat defaultFormat, + final boolean showHigherResolutions, + @Nullable final List videoStreams, + @Nullable final List videoOnlyStreams, + final boolean ascendingOrder, + final boolean preferVideoOnlyStreams + ) { + // Determine order of streams + // The last added list is preferred + final List> videoStreamsOrdered = + preferVideoOnlyStreams + ? Arrays.asList(videoStreams, videoOnlyStreams) + : Arrays.asList(videoOnlyStreams, videoStreams); + + final List allInitialStreams = videoStreamsOrdered.stream() + // Ignore lists that are null + .filter(Objects::nonNull) + .flatMap(List::stream) + // Filter out higher resolutions (or not if high resolutions should always be shown) + .filter(stream -> showHigherResolutions + || !HIGH_RESOLUTION_LIST.contains(stream.getResolution())) + .collect(Collectors.toList()); + final HashMap hashMap = new HashMap<>(); - - if (videoOnlyStreams != null) { - for (final VideoStream stream : videoOnlyStreams) { - if (!showHigherResolutions - && HIGH_RESOLUTION_LIST.contains(stream.getResolution())) { - continue; - } - retList.add(stream); - } - } - if (videoStreams != null) { - for (final VideoStream stream : videoStreams) { - if (!showHigherResolutions - && HIGH_RESOLUTION_LIST.contains(stream.getResolution())) { - continue; - } - retList.add(stream); - } - } - // Add all to the hashmap - for (final VideoStream videoStream : retList) { + for (final VideoStream videoStream : allInitialStreams) { hashMap.put(videoStream.getResolution(), videoStream); } // Override the values when the key == resolution, with the defaultFormat - for (final VideoStream videoStream : retList) { + for (final VideoStream videoStream : allInitialStreams) { if (videoStream.getFormat() == defaultFormat) { hashMap.put(videoStream.getResolution(), videoStream); } } - retList.clear(); - retList.addAll(hashMap.values()); - sortStreamList(retList, ascendingOrder); - return retList; + // Return the sorted list + return sortStreamList(new ArrayList<>(hashMap.values()), ascendingOrder); } /** @@ -257,16 +266,18 @@ public final class ListHelper { * 1080p -> 1080 * 1080p60 -> 1081 *
- * ascendingOrder ? 360 < 720 < 721 < 1080 < 1081 - * !ascendingOrder ? 1081 < 1080 < 721 < 720 < 360 + * ascendingOrder ? 360 < 720 < 721 < 1080 < 1081 + * !ascendingOrder ? 1081 < 1080 < 721 < 720 < 360 * * @param videoStreams list that the sorting will be applied * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest + * @return The sorted list (same reference as parameter videoStreams) */ - private static void sortStreamList(final List videoStreams, - final boolean ascendingOrder) { + private static List sortStreamList(final List videoStreams, + final boolean ascendingOrder) { final Comparator comparator = ListHelper::compareVideoStreamResolution; Collections.sort(videoStreams, ascendingOrder ? comparator : comparator.reversed()); + return videoStreams; } /** @@ -277,28 +288,12 @@ public final class ListHelper { * @param audioStreams List of audio streams * @return Index of audio stream that produces the most compact results or -1 if not found */ - static int getHighestQualityAudioIndex(@Nullable MediaFormat format, - final List audioStreams) { - int result = -1; - if (audioStreams != null) { - while (result == -1) { - AudioStream prevStream = null; - for (int idx = 0; idx < audioStreams.size(); idx++) { - final AudioStream stream = audioStreams.get(idx); - if ((format == null || stream.getFormat() == format) - && (prevStream == null || compareAudioStreamBitrate(prevStream, stream, - AUDIO_FORMAT_QUALITY_RANKING) < 0)) { - prevStream = stream; - result = idx; - } - } - if (result == -1 && format == null) { - break; - } - format = null; - } - } - return result; + static int getHighestQualityAudioIndex(@Nullable final MediaFormat format, + @Nullable final List audioStreams) { + return getAudioIndexByHighestRank(format, audioStreams, + // Compares descending (last = highest rank) + (s1, s2) -> compareAudioStreamBitrate(s1, s2, AUDIO_FORMAT_QUALITY_RANKING) + ); } /** @@ -309,28 +304,47 @@ public final class ListHelper { * @param audioStreams List of audio streams * @return Index of audio stream that produces the most compact results or -1 if not found */ - static int getMostCompactAudioIndex(@Nullable MediaFormat format, - final List audioStreams) { - int result = -1; - if (audioStreams != null) { - while (result == -1) { - AudioStream prevStream = null; - for (int idx = 0; idx < audioStreams.size(); idx++) { - final AudioStream stream = audioStreams.get(idx); - if ((format == null || stream.getFormat() == format) - && (prevStream == null || compareAudioStreamBitrate(prevStream, stream, - AUDIO_FORMAT_EFFICIENCY_RANKING) > 0)) { - prevStream = stream; - result = idx; - } - } - if (result == -1 && format == null) { - break; - } - format = null; - } + static int getMostCompactAudioIndex(@Nullable final MediaFormat format, + @Nullable final List audioStreams) { + + return getAudioIndexByHighestRank(format, audioStreams, + // The "-" is important -> Compares ascending (first = highest rank) + (s1, s2) -> -compareAudioStreamBitrate(s1, s2, AUDIO_FORMAT_EFFICIENCY_RANKING) + ); + } + + /** + * Get the audio-stream from the list with the highest rank, depending on the comparator. + * Format will be ignored if it yields no results. + * + * @param targetedFormat The target format type or null if it doesn't matter + * @param audioStreams List of audio streams + * @param comparator The comparator used for determining the max/best/highest ranked value + * @return Index of audio stream that produces the highest ranked result or -1 if not found + */ + private static int getAudioIndexByHighestRank(@Nullable final MediaFormat targetedFormat, + @Nullable final List audioStreams, + final Comparator comparator) { + if (audioStreams == null || audioStreams.isEmpty()) { + return -1; } - return result; + + final AudioStream highestRankedAudioStream = audioStreams.stream() + .filter(audioStream -> targetedFormat == null + || audioStream.getFormat() == targetedFormat) + .max(comparator) + .orElse(null); + + if (highestRankedAudioStream == null) { + // Fallback: Ignore targetedFormat if not null + if (targetedFormat != null) { + return getAudioIndexByHighestRank(null, audioStreams, comparator); + } + // targetedFormat is already null -> return -1 + return -1; + } + + return audioStreams.indexOf(highestRankedAudioStream); } /** 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 22e0a2dd0..e55114a2d 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.util; +import static org.schabi.newpipe.util.external_communication.ShareUtils.installApp; + import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; @@ -18,6 +20,8 @@ import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; +import com.jakewharton.processphoenix.ProcessPhoenix; + import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; @@ -31,6 +35,7 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.detail.VideoDetailFragment; @@ -57,10 +62,6 @@ import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.ArrayList; -import static org.schabi.newpipe.util.external_communication.ShareUtils.installApp; - -import com.jakewharton.processphoenix.ProcessPhoenix; - public final class NavigationHelper { public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag"; public static final String SEARCH_FRAGMENT_TAG = "search_fragment_tag"; @@ -214,7 +215,8 @@ public final class NavigationHelper { // External Players //////////////////////////////////////////////////////////////////////////*/ - public static void playOnExternalAudioPlayer(final Context context, final StreamInfo info) { + public static void playOnExternalAudioPlayer(@NonNull final Context context, + @NonNull final StreamInfo info) { final int index = ListHelper.getDefaultAudioFormat(context, info.getAudioStreams()); if (index == -1) { @@ -226,9 +228,11 @@ public final class NavigationHelper { playOnExternalPlayer(context, info.getName(), info.getUploaderName(), audioStream); } - public static void playOnExternalVideoPlayer(final Context context, final StreamInfo info) { + public static void playOnExternalVideoPlayer(@NonNull final Context context, + @NonNull final StreamInfo info) { final ArrayList videoStreamsList = new ArrayList<>( - ListHelper.getSortedStreamVideosList(context, info.getVideoStreams(), null, false)); + ListHelper.getSortedStreamVideosList(context, info.getVideoStreams(), null, false, + false)); final int index = ListHelper.getDefaultResolutionIndex(context, videoStreamsList); if (index == -1) { @@ -240,8 +244,10 @@ public final class NavigationHelper { playOnExternalPlayer(context, info.getName(), info.getUploaderName(), videoStream); } - public static void playOnExternalPlayer(final Context context, final String name, - final String artist, final Stream stream) { + public static void playOnExternalPlayer(@NonNull final Context context, + @Nullable final String name, + @Nullable final String artist, + @NonNull final Stream stream) { final Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); intent.setDataAndType(Uri.parse(stream.getUrl()), stream.getFormat().getMimeType()); @@ -253,7 +259,8 @@ public final class NavigationHelper { resolveActivityOrAskToInstall(context, intent); } - public static void resolveActivityOrAskToInstall(final Context context, final Intent intent) { + public static void resolveActivityOrAskToInstall(@NonNull final Context context, + @NonNull final Intent intent) { if (intent.resolveActivity(context.getPackageManager()) != null) { ShareUtils.openIntentInApp(context, intent, false); } else { @@ -402,6 +409,15 @@ public final class NavigationHelper { .commit(); } + public static void openChannelFragment(@NonNull final Fragment fragment, + @NonNull final StreamInfoItem item, + final String uploaderUrl) { + // For some reason `getParentFragmentManager()` doesn't work, but this does. + openChannelFragment( + fragment.requireActivity().getSupportFragmentManager(), + item.getServiceId(), uploaderUrl, item.getUploaderName()); + } + public static void openPlaylistFragment(final FragmentManager fragmentManager, final int serviceId, final String url, @NonNull final String name) { @@ -595,6 +611,12 @@ public final class NavigationHelper { return getOpenIntent(context, url, service.getServiceId(), linkType); } + public static Intent getChannelIntent(final Context context, + final int serviceId, + final String url) { + return getOpenIntent(context, url, serviceId, StreamingService.LinkType.CHANNEL); + } + /** * Start an activity to install Kore. * diff --git a/app/src/main/java/org/schabi/newpipe/util/PicassoHelper.java b/app/src/main/java/org/schabi/newpipe/util/PicassoHelper.java index e15ecd277..da86ab1a4 100644 --- a/app/src/main/java/org/schabi/newpipe/util/PicassoHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/PicassoHelper.java @@ -1,14 +1,18 @@ package org.schabi.newpipe.util; +import static org.schabi.newpipe.extractor.utils.Utils.isBlank; + import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; import com.squareup.picasso.Cache; import com.squareup.picasso.LruCache; import com.squareup.picasso.OkHttp3Downloader; import com.squareup.picasso.Picasso; import com.squareup.picasso.RequestCreator; +import com.squareup.picasso.Target; import com.squareup.picasso.Transformation; import org.schabi.newpipe.R; @@ -16,11 +20,10 @@ import org.schabi.newpipe.R; import java.io.File; import java.io.IOException; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import okhttp3.OkHttpClient; -import static org.schabi.newpipe.extractor.utils.Utils.isBlank; - public final class PicassoHelper { public static final String PLAYER_THUMBNAIL_TAG = "PICASSO_PLAYER_THUMBNAIL_TAG"; private static final String PLAYER_THUMBNAIL_TRANSFORMATION_KEY @@ -156,6 +159,28 @@ public final class PicassoHelper { } + public static void loadNotificationIcon(final String url, + final Consumer bitmapConsumer) { + loadImageDefault(url, R.drawable.ic_newpipe_triangle_white) + .into(new Target() { + @Override + public void onBitmapLoaded(final Bitmap bitmap, final Picasso.LoadedFrom from) { + bitmapConsumer.accept(bitmap); + } + + @Override + public void onBitmapFailed(final Exception e, final Drawable errorDrawable) { + bitmapConsumer.accept(null); + } + + @Override + public void onPrepareLoad(final Drawable placeHolderDrawable) { + // Nothing to do + } + }); + } + + private static RequestCreator loadImageDefault(final String url, final int placeholderResId) { if (!shouldLoadImages || isBlank(url)) { return picassoInstance diff --git a/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt b/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt new file mode 100644 index 000000000..21a9059e2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt @@ -0,0 +1,116 @@ +package org.schabi.newpipe.util + +import android.content.pm.PackageManager +import android.content.pm.Signature +import androidx.core.content.pm.PackageInfoCompat +import org.schabi.newpipe.App +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.ErrorUtil.Companion.createNotification +import org.schabi.newpipe.error.UserAction +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.security.cert.CertificateEncodingException +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.time.Instant +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +object ReleaseVersionUtil { + // Public key of the certificate that is used in NewPipe release versions + private const val RELEASE_CERT_PUBLIC_KEY_SHA1 = + "B0:2E:90:7C:1C:D6:FC:57:C3:35:F0:88:D0:8F:50:5F:94:E4:D2:15" + + @JvmStatic + fun isReleaseApk(): Boolean { + return certificateSHA1Fingerprint == RELEASE_CERT_PUBLIC_KEY_SHA1 + } + + /** + * Method to get the APK's SHA1 key. See https://stackoverflow.com/questions/9293019/#22506133. + * + * @return String with the APK's SHA1 fingerprint in hexadecimal + */ + private val certificateSHA1Fingerprint: String + get() { + val app = App.getApp() + val signatures: List = try { + PackageInfoCompat.getSignatures(app.packageManager, app.packageName) + } catch (e: PackageManager.NameNotFoundException) { + showRequestError(app, e, "Could not find package info") + return "" + } + if (signatures.isEmpty()) { + return "" + } + val x509cert = try { + val cert = signatures[0].toByteArray() + val input: InputStream = ByteArrayInputStream(cert) + val cf = CertificateFactory.getInstance("X509") + cf.generateCertificate(input) as X509Certificate + } catch (e: CertificateException) { + showRequestError(app, e, "Certificate error") + return "" + } + + return try { + val md = MessageDigest.getInstance("SHA1") + val publicKey = md.digest(x509cert.encoded) + byte2HexFormatted(publicKey) + } catch (e: NoSuchAlgorithmException) { + showRequestError(app, e, "Could not retrieve SHA1 key") + "" + } catch (e: CertificateEncodingException) { + showRequestError(app, e, "Could not retrieve SHA1 key") + "" + } + } + + private fun byte2HexFormatted(arr: ByteArray): String { + val str = StringBuilder(arr.size * 2) + for (i in arr.indices) { + var h = Integer.toHexString(arr[i].toInt()) + val l = h.length + if (l == 1) { + h = "0$h" + } + if (l > 2) { + h = h.substring(l - 2, l) + } + str.append(h.uppercase()) + if (i < arr.size - 1) { + str.append(':') + } + } + return str.toString() + } + + private fun showRequestError(app: App, e: Exception, request: String) { + createNotification( + app, ErrorInfo(e, UserAction.CHECK_FOR_NEW_APP_VERSION, request) + ) + } + + fun isLastUpdateCheckExpired(expiry: Long): Boolean { + return Instant.ofEpochSecond(expiry).isBefore(Instant.now()) + } + + /** + * Coerce expiry date time in between 6 hours and 72 hours from now + * + * @return Epoch second of expiry date time + */ + fun coerceUpdateCheckExpiry(expiryString: String?): Long { + val now = ZonedDateTime.now() + return expiryString?.let { + var expiry = + ZonedDateTime.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(expiryString)) + expiry = maxOf(expiry, now.plusHours(6)) + expiry = minOf(expiry, now.plusHours(72)) + expiry.toEpochSecond() + } ?: now.plusHours(6).toEpochSecond() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/SaveUploaderUrlHelper.java b/app/src/main/java/org/schabi/newpipe/util/SaveUploaderUrlHelper.java deleted file mode 100644 index 3c7b1ce91..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/SaveUploaderUrlHelper.java +++ /dev/null @@ -1,94 +0,0 @@ -package org.schabi.newpipe.util; - -import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; - -import android.content.Context; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.schedulers.Schedulers; - -/** - * Utility class for putting the uploader url into the database - when required. - */ -public final class SaveUploaderUrlHelper { - private SaveUploaderUrlHelper() { - } - - // Public functions which call the function that does - // the actual work with the correct parameters - public static void saveUploaderUrlIfNeeded(@NonNull final Fragment fragment, - @NonNull final StreamInfoItem infoItem, - @NonNull final SaveUploaderUrlCallback callback) { - saveUploaderUrlIfNeeded(fragment.requireContext(), - infoItem.getServiceId(), - infoItem.getUrl(), - infoItem.getUploaderUrl(), - callback); - } - public static void saveUploaderUrlIfNeeded(@NonNull final Context context, - @NonNull final PlayQueueItem queueItem, - @NonNull final SaveUploaderUrlCallback callback) { - saveUploaderUrlIfNeeded(context, - queueItem.getServiceId(), - queueItem.getUrl(), - queueItem.getUploaderUrl(), - callback); - } - - /** - * Fetches and saves the uploaderUrl if it is empty (meaning that it does - * not exist in the video item). The callback is called with either the - * fetched uploaderUrl, or the already saved uploaderUrl, but it is always - * called with a valid uploaderUrl that can be used to show channel details. - * - * @param context Context - * @param serviceId The serviceId of the item - * @param url The item url - * @param uploaderUrl The uploaderUrl of the item, if null or empty, it - * will be fetched using the item url. - * @param callback The callback that returns the fetched or existing - * uploaderUrl - */ - private static void saveUploaderUrlIfNeeded(@NonNull final Context context, - final int serviceId, - @NonNull final String url, - // Only used if not null or empty - @Nullable final String uploaderUrl, - @NonNull final SaveUploaderUrlCallback callback) { - if (isNullOrEmpty(uploaderUrl)) { - Toast.makeText(context, R.string.loading_channel_details, - Toast.LENGTH_SHORT).show(); - ExtractorHelper.getStreamInfo(serviceId, url, false) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - NewPipeDatabase.getInstance(context).streamDAO() - .setUploaderUrl(serviceId, url, result.getUploaderUrl()) - .subscribeOn(Schedulers.io()).subscribe(); - callback.onCallback(result.getUploaderUrl()); - }, throwable -> ErrorUtil.createNotification(context, - new ErrorInfo(throwable, UserAction.REQUESTED_CHANNEL, - "Could not load channel details") - )); - } else { - callback.onCallback(uploaderUrl); - } - } - - public interface SaveUploaderUrlCallback { - void onCallback(@NonNull String uploaderUrl); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/SerializedCache.java b/app/src/main/java/org/schabi/newpipe/util/SerializedCache.java index 9d97e013a..b4c196ce4 100644 --- a/app/src/main/java/org/schabi/newpipe/util/SerializedCache.java +++ b/app/src/main/java/org/schabi/newpipe/util/SerializedCache.java @@ -19,7 +19,7 @@ public final class SerializedCache { private static final boolean DEBUG = MainActivity.DEBUG; private static final SerializedCache INSTANCE = new SerializedCache(); private static final int MAX_ITEMS_ON_CACHE = 5; - private static final LruCache LRU_CACHE = + private static final LruCache> LRU_CACHE = new LruCache<>(MAX_ITEMS_ON_CACHE); private static final String TAG = "SerializedCache"; @@ -47,7 +47,7 @@ public final class SerializedCache { Log.d(TAG, "get() called with: key = [" + key + "]"); } synchronized (LRU_CACHE) { - final CacheData data = LRU_CACHE.get(key); + final CacheData data = LRU_CACHE.get(key); return data != null ? getItem(data, type) : null; } } @@ -91,7 +91,7 @@ public final class SerializedCache { } @Nullable - private T getItem(@NonNull final CacheData data, @NonNull final Class type) { + private T getItem(@NonNull final CacheData data, @NonNull final Class type) { return type.isAssignableFrom(data.type) ? type.cast(data.item) : null; } diff --git a/app/src/main/java/org/schabi/newpipe/util/SimpleOnSeekBarChangeListener.kt b/app/src/main/java/org/schabi/newpipe/util/SimpleOnSeekBarChangeListener.kt new file mode 100644 index 000000000..a79085fc0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/SimpleOnSeekBarChangeListener.kt @@ -0,0 +1,12 @@ +package org.schabi.newpipe.util + +import android.widget.SeekBar + +/** + * Why the hell didn't they make a stub implementation for this? + */ +abstract class SimpleOnSeekBarChangeListener : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {} + override fun onStartTrackingTouch(seekBar: SeekBar) {} + override fun onStopTrackingTouch(seekBar: SeekBar) {} +} diff --git a/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java b/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java new file mode 100644 index 000000000..b8cd4ef69 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java @@ -0,0 +1,129 @@ +package org.schabi.newpipe.util; + +import static org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM; +import static org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM; +import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; + +import android.content.Context; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipe.NewPipeDatabase; +import org.schabi.newpipe.R; +import org.schabi.newpipe.database.stream.model.StreamEntity; +import org.schabi.newpipe.error.ErrorInfo; +import org.schabi.newpipe.error.ErrorUtil; +import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.player.playqueue.SinglePlayQueue; + +import java.util.function.Consumer; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +/** + * Utility class for fetching additional data for stream items when needed. + */ +public final class SparseItemUtil { + private SparseItemUtil() { + } + + /** + * Use this to certainly obtain an single play queue with all of the data filled in when the + * stream info item you are handling might be sparse, e.g. because it was fetched via a {@link + * org.schabi.newpipe.extractor.feed.FeedExtractor}. FeedExtractors provide a fast and + * lightweight method to fetch info, but the info might be incomplete (see + * {@link org.schabi.newpipe.local.feed.service.FeedLoadService} for more details). + * + * @param context Android context + * @param item item which is checked and eventually loaded completely + * @param callback callback to call with the single play queue built from the original item if + * all info was available, otherwise from the fetched {@link + * org.schabi.newpipe.extractor.stream.StreamInfo} + */ + public static void fetchItemInfoIfSparse(@NonNull final Context context, + @NonNull final StreamInfoItem item, + @NonNull final Consumer callback) { + if (((item.getStreamType() == LIVE_STREAM || item.getStreamType() == AUDIO_LIVE_STREAM) + || item.getDuration() >= 0) && !isNullOrEmpty(item.getUploaderUrl())) { + // if the duration is >= 0 (provided that the item is not a livestream) and there is an + // uploader url, probably all info is already there, so there is no need to fetch it + callback.accept(new SinglePlayQueue(item)); + return; + } + + // either the duration or the uploader url are not available, so fetch more info + fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(), + streamInfo -> callback.accept(new SinglePlayQueue(streamInfo))); + } + + /** + * Use this to certainly obtain an uploader url when the stream info item or play queue item you + * are handling might not have the uploader url (e.g. because it was fetched with {@link + * org.schabi.newpipe.extractor.feed.FeedExtractor}). A toast is shown if loading details is + * required. + * + * @param context Android context + * @param serviceId serviceId of the item + * @param url item url + * @param uploaderUrl uploaderUrl of the item; if null or empty will be fetched + * @param callback callback to be called with either the original uploaderUrl, if it was a + * valid url, otherwise with the uploader url obtained by fetching the {@link + * org.schabi.newpipe.extractor.stream.StreamInfo} corresponding to the item + */ + public static void fetchUploaderUrlIfSparse(@NonNull final Context context, + final int serviceId, + @NonNull final String url, + @Nullable final String uploaderUrl, + @NonNull final Consumer callback) { + if (!isNullOrEmpty(uploaderUrl)) { + callback.accept(uploaderUrl); + return; + } + fetchStreamInfoAndSaveToDatabase(context, serviceId, url, + streamInfo -> callback.accept(streamInfo.getUploaderUrl())); + } + + /** + * Loads the stream info corresponding to the given data on an I/O thread, stores the result in + * the database and calls the callback on the main thread with the result. A toast will be shown + * to the user about loading stream details, so this needs to be called on the main thread. + * + * @param context Android context + * @param serviceId service id of the stream to load + * @param url url of the stream to load + * @param callback callback to be called with the result + */ + private static void fetchStreamInfoAndSaveToDatabase(@NonNull final Context context, + final int serviceId, + @NonNull final String url, + final Consumer callback) { + Toast.makeText(context, R.string.loading_stream_details, Toast.LENGTH_SHORT).show(); + ExtractorHelper.getStreamInfo(serviceId, url, false) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(result -> { + // save to database in the background (not on main thread) + Completable.fromAction(() -> NewPipeDatabase.getInstance(context) + .streamDAO().upsert(new StreamEntity(result))) + .subscribeOn(Schedulers.io()) + .observeOn(Schedulers.io()) + .doOnError(throwable -> + ErrorUtil.createNotification(context, + new ErrorInfo(throwable, UserAction.REQUESTED_STREAM, + "Saving stream info to database", result))) + .subscribe(); + + // call callback on main thread with the obtained result + callback.accept(result); + }, throwable -> ErrorUtil.createNotification(context, + new ErrorInfo(throwable, UserAction.REQUESTED_STREAM, + "Loading stream info: " + url, serviceId) + )); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java b/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java deleted file mode 100644 index 1b4c8046c..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java +++ /dev/null @@ -1,238 +0,0 @@ -package org.schabi.newpipe.util; - -import android.content.Context; -import android.net.Uri; -import android.util.Log; - -import androidx.fragment.app.Fragment; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.local.dialog.PlaylistAppendDialog; -import org.schabi.newpipe.local.dialog.PlaylistDialog; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.player.playqueue.SinglePlayQueue; -import org.schabi.newpipe.util.external_communication.KoreUtils; -import org.schabi.newpipe.util.external_communication.ShareUtils; - -import java.util.Collections; -import java.util.List; -import java.util.function.Consumer; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public enum StreamDialogEntry { - ////////////////////////////////////// - // enum values with DEFAULT actions // - ////////////////////////////////////// - - show_channel_details(R.string.show_channel_details, (fragment, item) -> { - SaveUploaderUrlHelper.saveUploaderUrlIfNeeded(fragment, item, - uploaderUrl -> openChannelFragment(fragment, item, uploaderUrl)); - }), - - /** - * Enqueues the stream automatically to the current PlayerType.
- *
- * Info: Add this entry within showStreamDialog. - */ - enqueue(R.string.enqueue_stream, (fragment, item) -> { - fetchItemInfoIfSparse(fragment, item, fullItem -> - NavigationHelper.enqueueOnPlayer(fragment.getContext(), fullItem)); - }), - - enqueue_next(R.string.enqueue_next_stream, (fragment, item) -> { - fetchItemInfoIfSparse(fragment, item, fullItem -> - NavigationHelper.enqueueNextOnPlayer(fragment.getContext(), fullItem)); - }), - - start_here_on_background(R.string.start_here_on_background, (fragment, item) -> { - fetchItemInfoIfSparse(fragment, item, fullItem -> - NavigationHelper.playOnBackgroundPlayer(fragment.getContext(), fullItem, true)); - }), - - start_here_on_popup(R.string.start_here_on_popup, (fragment, item) -> { - fetchItemInfoIfSparse(fragment, item, fullItem -> - NavigationHelper.playOnPopupPlayer(fragment.getContext(), fullItem, true)); - }), - - set_as_playlist_thumbnail(R.string.set_as_playlist_thumbnail, (fragment, item) -> { - }), // has to be set manually - - delete(R.string.delete, (fragment, item) -> { - }), // has to be set manually - - append_playlist(R.string.add_to_playlist, (fragment, item) -> { - PlaylistDialog.createCorrespondingDialog( - fragment.getContext(), - Collections.singletonList(new StreamEntity(item)), - dialog -> dialog.show( - fragment.getParentFragmentManager(), - "StreamDialogEntry@" - + (dialog instanceof PlaylistAppendDialog ? "append" : "create") - + "_playlist" - ) - ); - }), - - play_with_kodi(R.string.play_with_kodi_title, (fragment, item) -> { - final Uri videoUrl = Uri.parse(item.getUrl()); - try { - NavigationHelper.playWithKore(fragment.requireContext(), videoUrl); - } catch (final Exception e) { - KoreUtils.showInstallKoreDialog(fragment.requireActivity()); - } - }), - - share(R.string.share, (fragment, item) -> - ShareUtils.shareText(fragment.requireContext(), item.getName(), item.getUrl(), - item.getThumbnailUrl())), - - open_in_browser(R.string.open_in_browser, (fragment, item) -> - ShareUtils.openUrlInBrowser(fragment.requireContext(), item.getUrl())), - - - mark_as_watched(R.string.mark_as_watched, (fragment, item) -> { - new HistoryRecordManager(fragment.getContext()) - .markAsWatched(item) - .onErrorComplete() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(); - }); - - /////////////// - // variables // - /////////////// - - private static StreamDialogEntry[] enabledEntries; - private final int resource; - private final StreamDialogEntryAction defaultAction; - private StreamDialogEntryAction customAction; - - StreamDialogEntry(final int resource, final StreamDialogEntryAction defaultAction) { - this.resource = resource; - this.defaultAction = defaultAction; - this.customAction = null; - } - - - /////////////////////////////////////////////////////// - // non-static methods to initialize and edit entries // - /////////////////////////////////////////////////////// - - public static void setEnabledEntries(final List entries) { - setEnabledEntries(entries.toArray(new StreamDialogEntry[0])); - } - - /** - * To be called before using {@link #setCustomAction(StreamDialogEntryAction)}. - * - * @param entries the entries to be enabled - */ - public static void setEnabledEntries(final StreamDialogEntry... entries) { - // cleanup from last time StreamDialogEntry was used - for (final StreamDialogEntry streamDialogEntry : values()) { - streamDialogEntry.customAction = null; - } - - enabledEntries = entries; - } - - public static String[] getCommands(final Context context) { - final String[] commands = new String[enabledEntries.length]; - for (int i = 0; i != enabledEntries.length; ++i) { - commands[i] = context.getResources().getString(enabledEntries[i].resource); - } - - return commands; - } - - - //////////////////////////////////////////////// - // static methods that act on enabled entries // - //////////////////////////////////////////////// - - public static void clickOn(final int which, final Fragment fragment, - final StreamInfoItem infoItem) { - if (enabledEntries[which].customAction == null) { - enabledEntries[which].defaultAction.onClick(fragment, infoItem); - } else { - enabledEntries[which].customAction.onClick(fragment, infoItem); - } - } - - /** - * Can be used after {@link #setEnabledEntries(StreamDialogEntry...)} has been called. - * - * @param action the action to be set - */ - public void setCustomAction(final StreamDialogEntryAction action) { - this.customAction = action; - } - - public interface StreamDialogEntryAction { - void onClick(Fragment fragment, StreamInfoItem infoItem); - } - - public static boolean shouldAddMarkAsWatched(final StreamType streamType, - final Context context) { - final boolean isWatchHistoryEnabled = PreferenceManager - .getDefaultSharedPreferences(context) - .getBoolean(context.getString(R.string.enable_watch_history_key), false); - return streamType != StreamType.AUDIO_LIVE_STREAM - && streamType != StreamType.LIVE_STREAM - && isWatchHistoryEnabled; - } - - ///////////////////////////////////////////// - // private method to open channel fragment // - ///////////////////////////////////////////// - - private static void openChannelFragment(final Fragment fragment, - final StreamInfoItem item, - final String uploaderUrl) { - // For some reason `getParentFragmentManager()` doesn't work, but this does. - NavigationHelper.openChannelFragment( - fragment.requireActivity().getSupportFragmentManager(), - item.getServiceId(), uploaderUrl, item.getUploaderName()); - } - - ///////////////////////////////////////////// - // helper functions // - ///////////////////////////////////////////// - - private static void fetchItemInfoIfSparse(final Fragment fragment, - final StreamInfoItem item, - final Consumer callback) { - if (!(item.getStreamType() == StreamType.LIVE_STREAM - || item.getStreamType() == StreamType.AUDIO_LIVE_STREAM) - && item.getDuration() < 0) { - // Sparse item: fetched by fast fetch - ExtractorHelper.getStreamInfo( - item.getServiceId(), - item.getUrl(), - false - ) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - final HistoryRecordManager recordManager = - new HistoryRecordManager(fragment.getContext()); - recordManager.saveStreamState(result, 0) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError(throwable -> Log.e("StreamDialogEntry", - throwable.toString())) - .subscribe(); - - callback.accept(new SinglePlayQueue(result)); - }, throwable -> Log.e("StreamDialogEntry", throwable.toString())); - } else { - callback.accept(new SinglePlayQueue(item)); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java index 9150b5c1a..03342a497 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java @@ -12,6 +12,7 @@ import android.widget.TextView; import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.SubtitlesStream; @@ -41,16 +42,21 @@ public class StreamItemAdapter extends BaseA private final StreamSizeWrapper streamsWrapper; private final SparseArray> secondaryStreams; + /** + * Indicates that at least one of the primary streams is an instance of {@link VideoStream}, + * has no audio ({@link VideoStream#isVideoOnly()} returns true) and has no secondary stream + * associated with it. + */ + private final boolean hasAnyVideoOnlyStreamWithNoSecondaryStream; + public StreamItemAdapter(final Context context, final StreamSizeWrapper streamsWrapper, final SparseArray> secondaryStreams) { this.context = context; this.streamsWrapper = streamsWrapper; this.secondaryStreams = secondaryStreams; - } - public StreamItemAdapter(final Context context, final StreamSizeWrapper streamsWrapper, - final boolean showIconNoAudio) { - this(context, streamsWrapper, showIconNoAudio ? new SparseArray<>() : null); + this.hasAnyVideoOnlyStreamWithNoSecondaryStream = + checkHasAnyVideoOnlyStreamWithNoSecondaryStream(); } public StreamItemAdapter(final Context context, final StreamSizeWrapper streamsWrapper) { @@ -114,10 +120,15 @@ public class StreamItemAdapter extends BaseA final VideoStream videoStream = ((VideoStream) stream); qualityString = videoStream.getResolution(); - if (secondaryStreams != null) { + if (hasAnyVideoOnlyStreamWithNoSecondaryStream) { if (videoStream.isVideoOnly()) { - woSoundIconVisibility = secondaryStreams.get(position) == null ? View.VISIBLE - : View.INVISIBLE; + woSoundIconVisibility = hasSecondaryStream(position) + // It has a secondary stream associated with it, so check if it's a + // dropdown view so it doesn't look out of place (missing margin) + // compared to those that don't. + ? (isDropdownItem ? View.INVISIBLE : View.GONE) + // It doesn't have a secondary stream, icon is visible no matter what. + : View.VISIBLE; } else if (isDropdownItem) { woSoundIconVisibility = View.INVISIBLE; } @@ -137,7 +148,7 @@ public class StreamItemAdapter extends BaseA } if (streamsWrapper.getSizeInBytes(position) > 0) { - final SecondaryStreamHelper secondary = secondaryStreams == null ? null + final SecondaryStreamHelper secondary = secondaryStreams == null ? null : secondaryStreams.get(position); if (secondary != null) { final long size @@ -153,16 +164,11 @@ public class StreamItemAdapter extends BaseA if (stream instanceof SubtitlesStream) { formatNameView.setText(((SubtitlesStream) stream).getLanguageTag()); + } else if (stream.getFormat() == MediaFormat.WEBMA_OPUS) { + // noinspection AndroidLintSetTextI18n + formatNameView.setText("opus"); } else { - switch (stream.getFormat()) { - case WEBMA_OPUS: - // noinspection AndroidLintSetTextI18n - formatNameView.setText("opus"); - break; - default: - formatNameView.setText(stream.getFormat().getName()); - break; - } + formatNameView.setText(stream.getFormat().getName()); } qualityView.setText(qualityString); @@ -171,6 +177,32 @@ public class StreamItemAdapter extends BaseA return convertView; } + /** + * @param position which primary stream to check. + * @return whether the primary stream at position has a secondary stream associated with it. + */ + private boolean hasSecondaryStream(final int position) { + return secondaryStreams != null && secondaryStreams.get(position) != null; + } + + /** + * @return if there are any video-only streams with no secondary stream associated with them. + * @see #hasAnyVideoOnlyStreamWithNoSecondaryStream + */ + private boolean checkHasAnyVideoOnlyStreamWithNoSecondaryStream() { + for (int i = 0; i < streamsWrapper.getStreamsList().size(); i++) { + final T stream = streamsWrapper.getStreamsList().get(i); + if (stream instanceof VideoStream) { + final boolean videoOnly = ((VideoStream) stream).isVideoOnly(); + if (videoOnly && !hasSecondaryStream(i)) { + return true; + } + } + } + + return false; + } + /** * A wrapper class that includes a way of storing the stream sizes. * diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java index 6801f24ef..0df579d88 100644 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java @@ -10,6 +10,10 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.util.NavigationHelper; +/** + * Util class that provides methods which are related to the Kodi Media Center and its Kore app. + * @see Kodi website + */ public final class KoreUtils { private KoreUtils() { } diff --git a/app/src/main/java/org/schabi/newpipe/views/LargeTextMovementMethod.java b/app/src/main/java/org/schabi/newpipe/views/LargeTextMovementMethod.java deleted file mode 100644 index 1219304e1..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/LargeTextMovementMethod.java +++ /dev/null @@ -1,303 +0,0 @@ -/* - * Copyright 2019 Alexander Rvachev - * FocusOverlayView.java is part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.schabi.newpipe.views; - -import android.graphics.Rect; -import android.text.Layout; -import android.text.Selection; -import android.text.Spannable; -import android.text.method.LinkMovementMethod; -import android.text.style.ClickableSpan; -import android.view.KeyEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewParent; -import android.widget.TextView; - -public class LargeTextMovementMethod extends LinkMovementMethod { - private final Rect visibleRect = new Rect(); - - private int direction; - - @Override - public void onTakeFocus(final TextView view, final Spannable text, final int dir) { - Selection.removeSelection(text); - - super.onTakeFocus(view, text, dir); - - this.direction = dirToRelative(dir); - } - - @Override - protected boolean handleMovementKey(final TextView widget, - final Spannable buffer, - final int keyCode, - final int movementMetaState, - final KeyEvent event) { - if (!doHandleMovement(widget, buffer, keyCode, movementMetaState, event)) { - // clear selection to make sure, that it does not confuse focus handling code - Selection.removeSelection(buffer); - return false; - } - - return true; - } - - private boolean doHandleMovement(final TextView widget, - final Spannable buffer, - final int keyCode, - final int movementMetaState, - final KeyEvent event) { - final int newDir = keyToDir(keyCode); - - if (direction != 0 && newDir != direction) { - return false; - } - - this.direction = 0; - - final ViewGroup root = findScrollableParent(widget); - - widget.getHitRect(visibleRect); - - root.offsetDescendantRectToMyCoords((View) widget.getParent(), visibleRect); - - return super.handleMovementKey(widget, buffer, keyCode, movementMetaState, event); - } - - @Override - protected boolean up(final TextView widget, final Spannable buffer) { - if (gotoPrev(widget, buffer)) { - return true; - } - - return super.up(widget, buffer); - } - - @Override - protected boolean left(final TextView widget, final Spannable buffer) { - if (gotoPrev(widget, buffer)) { - return true; - } - - return super.left(widget, buffer); - } - - @Override - protected boolean right(final TextView widget, final Spannable buffer) { - if (gotoNext(widget, buffer)) { - return true; - } - - return super.right(widget, buffer); - } - - @Override - protected boolean down(final TextView widget, final Spannable buffer) { - if (gotoNext(widget, buffer)) { - return true; - } - - return super.down(widget, buffer); - } - - private boolean gotoPrev(final TextView view, final Spannable buffer) { - final Layout layout = view.getLayout(); - if (layout == null) { - return false; - } - - final View root = findScrollableParent(view); - - final int rootHeight = root.getHeight(); - - if (visibleRect.top >= 0) { - // we fit entirely into the viewport, no need for fancy footwork - return false; - } - - final int topExtra = -visibleRect.top; - - final int firstVisibleLineNumber = layout.getLineForVertical(topExtra); - - // when deciding whether to pass "focus" to span, account for one more line - // this ensures, that focus is never passed to spans partially outside scroll window - final int visibleStart = firstVisibleLineNumber == 0 - ? 0 - : layout.getLineStart(firstVisibleLineNumber - 1); - - final ClickableSpan[] candidates = buffer.getSpans( - visibleStart, buffer.length(), ClickableSpan.class); - - if (candidates.length != 0) { - final int a = Selection.getSelectionStart(buffer); - final int b = Selection.getSelectionEnd(buffer); - - final int selStart = Math.min(a, b); - final int selEnd = Math.max(a, b); - - int bestStart = -1; - int bestEnd = -1; - - for (final ClickableSpan candidate : candidates) { - final int start = buffer.getSpanStart(candidate); - final int end = buffer.getSpanEnd(candidate); - - if ((end < selEnd || selStart == selEnd) && start >= visibleStart) { - if (end > bestEnd) { - bestStart = buffer.getSpanStart(candidate); - bestEnd = end; - } - } - } - - if (bestStart >= 0) { - Selection.setSelection(buffer, bestEnd, bestStart); - return true; - } - } - - final float fourLines = view.getTextSize() * 4; - - visibleRect.left = 0; - visibleRect.right = view.getWidth(); - visibleRect.top = Math.max(0, (int) (topExtra - fourLines)); - visibleRect.bottom = visibleRect.top + rootHeight; - - return view.requestRectangleOnScreen(visibleRect); - } - - private boolean gotoNext(final TextView view, final Spannable buffer) { - final Layout layout = view.getLayout(); - if (layout == null) { - return false; - } - - final View root = findScrollableParent(view); - - final int rootHeight = root.getHeight(); - - if (visibleRect.bottom <= rootHeight) { - // we fit entirely into the viewport, no need for fancy footwork - return false; - } - - final int bottomExtra = visibleRect.bottom - rootHeight; - - final int visibleBottomBorder = view.getHeight() - bottomExtra; - - final int lineCount = layout.getLineCount(); - - final int lastVisibleLineNumber = layout.getLineForVertical(visibleBottomBorder); - - // when deciding whether to pass "focus" to span, account for one more line - // this ensures, that focus is never passed to spans partially outside scroll window - final int visibleEnd = lastVisibleLineNumber == lineCount - 1 - ? buffer.length() - : layout.getLineEnd(lastVisibleLineNumber - 1); - - final ClickableSpan[] candidates = buffer.getSpans(0, visibleEnd, ClickableSpan.class); - - if (candidates.length != 0) { - final int a = Selection.getSelectionStart(buffer); - final int b = Selection.getSelectionEnd(buffer); - - final int selStart = Math.min(a, b); - final int selEnd = Math.max(a, b); - - int bestStart = Integer.MAX_VALUE; - int bestEnd = Integer.MAX_VALUE; - - for (final ClickableSpan candidate : candidates) { - final int start = buffer.getSpanStart(candidate); - final int end = buffer.getSpanEnd(candidate); - - if ((start > selStart || selStart == selEnd) && end <= visibleEnd) { - if (start < bestStart) { - bestStart = start; - bestEnd = buffer.getSpanEnd(candidate); - } - } - } - - if (bestEnd < Integer.MAX_VALUE) { - // cool, we have managed to find next link without having to adjust self within view - Selection.setSelection(buffer, bestStart, bestEnd); - return true; - } - } - - // there are no links within visible area, but still some text past visible area - // scroll visible area further in required direction - final float fourLines = view.getTextSize() * 4; - - visibleRect.left = 0; - visibleRect.right = view.getWidth(); - visibleRect.bottom = Math.min((int) (visibleBottomBorder + fourLines), view.getHeight()); - visibleRect.top = visibleRect.bottom - rootHeight; - - return view.requestRectangleOnScreen(visibleRect); - } - - private ViewGroup findScrollableParent(final View view) { - View current = view; - - ViewParent parent; - do { - parent = current.getParent(); - - if (parent == current || !(parent instanceof View)) { - return (ViewGroup) view.getRootView(); - } - - current = (View) parent; - - if (current.isScrollContainer()) { - return (ViewGroup) current; - } - } - while (true); - } - - private static int dirToRelative(final int dir) { - switch (dir) { - case View.FOCUS_DOWN: - case View.FOCUS_RIGHT: - return View.FOCUS_FORWARD; - case View.FOCUS_UP: - case View.FOCUS_LEFT: - return View.FOCUS_BACKWARD; - } - - return dir; - } - - private int keyToDir(final int keyCode) { - switch (keyCode) { - case KeyEvent.KEYCODE_DPAD_UP: - case KeyEvent.KEYCODE_DPAD_LEFT: - return View.FOCUS_BACKWARD; - case KeyEvent.KEYCODE_DPAD_DOWN: - case KeyEvent.KEYCODE_DPAD_RIGHT: - return View.FOCUS_FORWARD; - } - - return View.FOCUS_FORWARD; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/player/SecondsView.kt b/app/src/main/java/org/schabi/newpipe/views/player/SecondsView.kt index d209d24da..8472653fb 100644 --- a/app/src/main/java/org/schabi/newpipe/views/player/SecondsView.kt +++ b/app/src/main/java/org/schabi/newpipe/views/player/SecondsView.kt @@ -1,11 +1,11 @@ package org.schabi.newpipe.views.player -import android.animation.Animator import android.animation.ValueAnimator import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.widget.LinearLayout +import androidx.core.animation.addListener import org.schabi.newpipe.R import org.schabi.newpipe.databinding.PlayerFastSeekSecondsViewBinding import org.schabi.newpipe.util.DeviceUtils @@ -163,19 +163,10 @@ class SecondsView(context: Context, attrs: AttributeSet?) : LinearLayout(context setFloatValues(0f, 1f) addUpdateListener { update(it.animatedValue as Float) } - addListener(object : AnimatorListener { - override fun onAnimationStart(animation: Animator?) { - start() - } - - override fun onAnimationEnd(animation: Animator?) { - end() - } - - override fun onAnimationCancel(animation: Animator?) = Unit - - override fun onAnimationRepeat(animation: Animator?) = Unit - }) + addListener( + onStart = { start() }, + onEnd = { end() } + ) } } } diff --git a/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.kt b/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.kt index bba6fdd7f..11293a610 100644 --- a/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.kt +++ b/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.kt @@ -26,8 +26,8 @@ class MissionRecoveryInfo( kind = 'a' } is VideoStream -> { - desired = stream.getResolution() - isDesired2 = stream.isVideoOnly() + desired = stream.resolution + isDesired2 = stream.isVideoOnly kind = 'v' } is SubtitlesStream -> { diff --git a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java index dda2d6dee..b5fc0297c 100644 --- a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java +++ b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java @@ -192,14 +192,7 @@ public class MissionsFragment extends Fragment { updateList(); return true; case R.id.clear_list: - AlertDialog.Builder prompt = new AlertDialog.Builder(mContext); - prompt.setTitle(R.string.clear_download_history); - prompt.setMessage(R.string.confirm_prompt); - // Intentionally misusing button's purpose in order to achieve good order - prompt.setNegativeButton(R.string.clear_download_history, (dialog, which) -> mAdapter.clearFinishedDownloads(false)); - prompt.setPositiveButton(R.string.delete_downloaded_files, (dialog, which) -> mAdapter.clearFinishedDownloads(true)); - prompt.setNeutralButton(R.string.cancel, null); - prompt.create().show(); + showClearDownloadHistoryPrompt(); return true; case R.id.start_downloads: mBinder.getDownloadManager().startAllMissions(); @@ -212,6 +205,32 @@ public class MissionsFragment extends Fragment { } } + public void showClearDownloadHistoryPrompt() { + // ask the user whether he wants to just clear history or instead delete files on disk + new AlertDialog.Builder(mContext) + .setTitle(R.string.clear_download_history) + .setMessage(R.string.confirm_prompt) + // Intentionally misusing buttons' purpose in order to achieve good order + .setNegativeButton(R.string.clear_download_history, + (dialog, which) -> mAdapter.clearFinishedDownloads(false)) + .setNeutralButton(R.string.cancel, null) + .setPositiveButton(R.string.delete_downloaded_files, + (dialog, which) -> showDeleteDownloadedFilesConfirmationPrompt()) + .create() + .show(); + } + + public void showDeleteDownloadedFilesConfirmationPrompt() { + // make sure the user confirms once more before deleting files on disk + new AlertDialog.Builder(mContext) + .setTitle(R.string.delete_downloaded_files_confirm) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.ok, + (dialog, which) -> mAdapter.clearFinishedDownloads(true)) + .create() + .show(); + } + private void updateList() { if (mLinear) { mList.setLayoutManager(mLinearManager); diff --git a/app/src/main/res/drawable-night/ic_add.xml b/app/src/main/res/drawable-night/ic_add.xml deleted file mode 100644 index bbda803b0..000000000 --- a/app/src/main/res/drawable-night/ic_add.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_add_circle_outline.xml b/app/src/main/res/drawable-night/ic_add_circle_outline.xml deleted file mode 100644 index 2f2cfe3e3..000000000 --- a/app/src/main/res/drawable-night/ic_add_circle_outline.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_apps.xml b/app/src/main/res/drawable-night/ic_apps.xml deleted file mode 100644 index 2d7d796f7..000000000 --- a/app/src/main/res/drawable-night/ic_apps.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_arrow_back.xml b/app/src/main/res/drawable-night/ic_arrow_back.xml deleted file mode 100644 index b7c728783..000000000 --- a/app/src/main/res/drawable-night/ic_arrow_back.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_asterisk.xml b/app/src/main/res/drawable-night/ic_asterisk.xml deleted file mode 100644 index c66bb4051..000000000 --- a/app/src/main/res/drawable-night/ic_asterisk.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_attach_money.xml b/app/src/main/res/drawable-night/ic_attach_money.xml deleted file mode 100644 index fcc1ab160..000000000 --- a/app/src/main/res/drawable-night/ic_attach_money.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_backup.xml b/app/src/main/res/drawable-night/ic_backup.xml deleted file mode 100644 index 29259b0e0..000000000 --- a/app/src/main/res/drawable-night/ic_backup.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_bookmark.xml b/app/src/main/res/drawable-night/ic_bookmark.xml deleted file mode 100644 index 2e919f18d..000000000 --- a/app/src/main/res/drawable-night/ic_bookmark.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_bug_report.xml b/app/src/main/res/drawable-night/ic_bug_report.xml deleted file mode 100644 index e1a204a29..000000000 --- a/app/src/main/res/drawable-night/ic_bug_report.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_campaign.xml b/app/src/main/res/drawable-night/ic_campaign.xml deleted file mode 100644 index eabaddaee..000000000 --- a/app/src/main/res/drawable-night/ic_campaign.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_cast.xml b/app/src/main/res/drawable-night/ic_cast.xml deleted file mode 100644 index 61a1f61fe..000000000 --- a/app/src/main/res/drawable-night/ic_cast.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_child_care.xml b/app/src/main/res/drawable-night/ic_child_care.xml deleted file mode 100644 index 9375e3116..000000000 --- a/app/src/main/res/drawable-night/ic_child_care.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable-night/ic_close.xml b/app/src/main/res/drawable-night/ic_close.xml deleted file mode 100644 index c63eeb597..000000000 --- a/app/src/main/res/drawable-night/ic_close.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_cloud_download.xml b/app/src/main/res/drawable-night/ic_cloud_download.xml deleted file mode 100644 index 67e870456..000000000 --- a/app/src/main/res/drawable-night/ic_cloud_download.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_computer.xml b/app/src/main/res/drawable-night/ic_computer.xml deleted file mode 100644 index 68f85594d..000000000 --- a/app/src/main/res/drawable-night/ic_computer.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_crop_portrait.xml b/app/src/main/res/drawable-night/ic_crop_portrait.xml deleted file mode 100644 index fc11eba57..000000000 --- a/app/src/main/res/drawable-night/ic_crop_portrait.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_delete.xml b/app/src/main/res/drawable-night/ic_delete.xml deleted file mode 100644 index 3760de238..000000000 --- a/app/src/main/res/drawable-night/ic_delete.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_directions_bike.xml b/app/src/main/res/drawable-night/ic_directions_bike.xml deleted file mode 100644 index 90c7f7a77..000000000 --- a/app/src/main/res/drawable-night/ic_directions_bike.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_directions_car.xml b/app/src/main/res/drawable-night/ic_directions_car.xml deleted file mode 100644 index 26404bddb..000000000 --- a/app/src/main/res/drawable-night/ic_directions_car.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_done.xml b/app/src/main/res/drawable-night/ic_done.xml deleted file mode 100644 index bb657f6ec..000000000 --- a/app/src/main/res/drawable-night/ic_done.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_expand_more.xml b/app/src/main/res/drawable-night/ic_expand_more.xml deleted file mode 100644 index b6a470043..000000000 --- a/app/src/main/res/drawable-night/ic_expand_more.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_explore.xml b/app/src/main/res/drawable-night/ic_explore.xml deleted file mode 100644 index a910c5429..000000000 --- a/app/src/main/res/drawable-night/ic_explore.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_fastfood.xml b/app/src/main/res/drawable-night/ic_fastfood.xml deleted file mode 100644 index ddb9b6257..000000000 --- a/app/src/main/res/drawable-night/ic_fastfood.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_favorite.xml b/app/src/main/res/drawable-night/ic_favorite.xml deleted file mode 100644 index efc717ee9..000000000 --- a/app/src/main/res/drawable-night/ic_favorite.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_file_download.xml b/app/src/main/res/drawable-night/ic_file_download.xml deleted file mode 100644 index 97bdac0f1..000000000 --- a/app/src/main/res/drawable-night/ic_file_download.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_filter_list.xml b/app/src/main/res/drawable-night/ic_filter_list.xml deleted file mode 100644 index 2df495e15..000000000 --- a/app/src/main/res/drawable-night/ic_filter_list.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_fitness_center.xml b/app/src/main/res/drawable-night/ic_fitness_center.xml deleted file mode 100644 index 892def491..000000000 --- a/app/src/main/res/drawable-night/ic_fitness_center.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_headset.xml b/app/src/main/res/drawable-night/ic_headset.xml deleted file mode 100644 index f23764766..000000000 --- a/app/src/main/res/drawable-night/ic_headset.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_help.xml b/app/src/main/res/drawable-night/ic_help.xml deleted file mode 100644 index 04c1c00fc..000000000 --- a/app/src/main/res/drawable-night/ic_help.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_history.xml b/app/src/main/res/drawable-night/ic_history.xml deleted file mode 100644 index 2418fd6f9..000000000 --- a/app/src/main/res/drawable-night/ic_history.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_home.xml b/app/src/main/res/drawable-night/ic_home.xml deleted file mode 100644 index 12afe9051..000000000 --- a/app/src/main/res/drawable-night/ic_home.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_info_outline.xml b/app/src/main/res/drawable-night/ic_info_outline.xml deleted file mode 100644 index 085665e4b..000000000 --- a/app/src/main/res/drawable-night/ic_info_outline.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_insert_emoticon.xml b/app/src/main/res/drawable-night/ic_insert_emoticon.xml deleted file mode 100644 index de8e66530..000000000 --- a/app/src/main/res/drawable-night/ic_insert_emoticon.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_language.xml b/app/src/main/res/drawable-night/ic_language.xml deleted file mode 100644 index 9b97aa592..000000000 --- a/app/src/main/res/drawable-night/ic_language.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_list.xml b/app/src/main/res/drawable-night/ic_list.xml deleted file mode 100644 index 4fd341d82..000000000 --- a/app/src/main/res/drawable-night/ic_list.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_live_tv.xml b/app/src/main/res/drawable-night/ic_live_tv.xml deleted file mode 100644 index 303858f9d..000000000 --- a/app/src/main/res/drawable-night/ic_live_tv.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_mic.xml b/app/src/main/res/drawable-night/ic_mic.xml deleted file mode 100644 index c0c92fcc7..000000000 --- a/app/src/main/res/drawable-night/ic_mic.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_more_vert.xml b/app/src/main/res/drawable-night/ic_more_vert.xml deleted file mode 100644 index 19703e8e7..000000000 --- a/app/src/main/res/drawable-night/ic_more_vert.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_motorcycle.xml b/app/src/main/res/drawable-night/ic_motorcycle.xml deleted file mode 100644 index 4ffd8b451..000000000 --- a/app/src/main/res/drawable-night/ic_motorcycle.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_movie.xml b/app/src/main/res/drawable-night/ic_movie.xml deleted file mode 100644 index 79f93d1c1..000000000 --- a/app/src/main/res/drawable-night/ic_movie.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_music_note.xml b/app/src/main/res/drawable-night/ic_music_note.xml deleted file mode 100644 index ca80ad5ad..000000000 --- a/app/src/main/res/drawable-night/ic_music_note.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_palette.xml b/app/src/main/res/drawable-night/ic_palette.xml deleted file mode 100644 index 8edcceb76..000000000 --- a/app/src/main/res/drawable-night/ic_palette.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_pause.xml b/app/src/main/res/drawable-night/ic_pause.xml deleted file mode 100644 index ea843aff3..000000000 --- a/app/src/main/res/drawable-night/ic_pause.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_people.xml b/app/src/main/res/drawable-night/ic_people.xml deleted file mode 100644 index 8b925badc..000000000 --- a/app/src/main/res/drawable-night/ic_people.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_person.xml b/app/src/main/res/drawable-night/ic_person.xml deleted file mode 100644 index 5efaaf0dd..000000000 --- a/app/src/main/res/drawable-night/ic_person.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_pets.xml b/app/src/main/res/drawable-night/ic_pets.xml deleted file mode 100644 index 14373a3c5..000000000 --- a/app/src/main/res/drawable-night/ic_pets.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable-night/ic_picture_in_picture.xml b/app/src/main/res/drawable-night/ic_picture_in_picture.xml deleted file mode 100644 index 1b01f3233..000000000 --- a/app/src/main/res/drawable-night/ic_picture_in_picture.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_pin.xml b/app/src/main/res/drawable-night/ic_pin.xml deleted file mode 100644 index 6fe406341..000000000 --- a/app/src/main/res/drawable-night/ic_pin.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable-night/ic_play_arrow.xml b/app/src/main/res/drawable-night/ic_play_arrow.xml deleted file mode 100644 index 95cace1c8..000000000 --- a/app/src/main/res/drawable-night/ic_play_arrow.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_playlist_add.xml b/app/src/main/res/drawable-night/ic_playlist_add.xml deleted file mode 100644 index bf86fd24a..000000000 --- a/app/src/main/res/drawable-night/ic_playlist_add.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_playlist_add_check.xml b/app/src/main/res/drawable-night/ic_playlist_add_check.xml deleted file mode 100644 index a69d284a1..000000000 --- a/app/src/main/res/drawable-night/ic_playlist_add_check.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - diff --git a/app/src/main/res/drawable-night/ic_public.xml b/app/src/main/res/drawable-night/ic_public.xml deleted file mode 100644 index 6ae97422a..000000000 --- a/app/src/main/res/drawable-night/ic_public.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_radio.xml b/app/src/main/res/drawable-night/ic_radio.xml deleted file mode 100644 index d0902426b..000000000 --- a/app/src/main/res/drawable-night/ic_radio.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_refresh.xml b/app/src/main/res/drawable-night/ic_refresh.xml deleted file mode 100644 index 4ca5e73a7..000000000 --- a/app/src/main/res/drawable-night/ic_refresh.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_restaurant.xml b/app/src/main/res/drawable-night/ic_restaurant.xml deleted file mode 100644 index dbb849680..000000000 --- a/app/src/main/res/drawable-night/ic_restaurant.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_rss_feed.xml b/app/src/main/res/drawable-night/ic_rss_feed.xml deleted file mode 100644 index 193f4fe92..000000000 --- a/app/src/main/res/drawable-night/ic_rss_feed.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable-night/ic_save.xml b/app/src/main/res/drawable-night/ic_save.xml deleted file mode 100644 index b32b11451..000000000 --- a/app/src/main/res/drawable-night/ic_save.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_school.xml b/app/src/main/res/drawable-night/ic_school.xml deleted file mode 100644 index dc16c4782..000000000 --- a/app/src/main/res/drawable-night/ic_school.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_search.xml b/app/src/main/res/drawable-night/ic_search.xml deleted file mode 100644 index 4d0f18584..000000000 --- a/app/src/main/res/drawable-night/ic_search.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_search_add.xml b/app/src/main/res/drawable-night/ic_search_add.xml deleted file mode 100644 index 856433e41..000000000 --- a/app/src/main/res/drawable-night/ic_search_add.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_select_all.xml b/app/src/main/res/drawable-night/ic_select_all.xml deleted file mode 100644 index 157734911..000000000 --- a/app/src/main/res/drawable-night/ic_select_all.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_settings.xml b/app/src/main/res/drawable-night/ic_settings.xml deleted file mode 100644 index 61ee02ee0..000000000 --- a/app/src/main/res/drawable-night/ic_settings.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_share.xml b/app/src/main/res/drawable-night/ic_share.xml deleted file mode 100644 index 9dad7b85f..000000000 --- a/app/src/main/res/drawable-night/ic_share.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_shopping_cart.xml b/app/src/main/res/drawable-night/ic_shopping_cart.xml deleted file mode 100644 index 75c330cef..000000000 --- a/app/src/main/res/drawable-night/ic_shopping_cart.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_sort.xml b/app/src/main/res/drawable-night/ic_sort.xml deleted file mode 100644 index 484be5ad2..000000000 --- a/app/src/main/res/drawable-night/ic_sort.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_stars.xml b/app/src/main/res/drawable-night/ic_stars.xml deleted file mode 100644 index 135980afe..000000000 --- a/app/src/main/res/drawable-night/ic_stars.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_telescope.xml b/app/src/main/res/drawable-night/ic_telescope.xml deleted file mode 100644 index 86468f34a..000000000 --- a/app/src/main/res/drawable-night/ic_telescope.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_thumb_down.xml b/app/src/main/res/drawable-night/ic_thumb_down.xml deleted file mode 100644 index 1ee3ed018..000000000 --- a/app/src/main/res/drawable-night/ic_thumb_down.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_thumb_up.xml b/app/src/main/res/drawable-night/ic_thumb_up.xml deleted file mode 100644 index c4e387866..000000000 --- a/app/src/main/res/drawable-night/ic_thumb_up.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_trending_up.xml b/app/src/main/res/drawable-night/ic_trending_up.xml deleted file mode 100644 index ca4eb654b..000000000 --- a/app/src/main/res/drawable-night/ic_trending_up.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_tv.xml b/app/src/main/res/drawable-night/ic_tv.xml deleted file mode 100644 index b9d14869b..000000000 --- a/app/src/main/res/drawable-night/ic_tv.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_videogame_asset.xml b/app/src/main/res/drawable-night/ic_videogame_asset.xml deleted file mode 100644 index 4861bf809..000000000 --- a/app/src/main/res/drawable-night/ic_videogame_asset.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_visibility_off.xml b/app/src/main/res/drawable-night/ic_visibility_off.xml deleted file mode 100644 index 689f3f47c..000000000 --- a/app/src/main/res/drawable-night/ic_visibility_off.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_visibility_on.xml b/app/src/main/res/drawable-night/ic_visibility_on.xml deleted file mode 100644 index e02f1d191..000000000 --- a/app/src/main/res/drawable-night/ic_visibility_on.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_volume_off.xml b/app/src/main/res/drawable-night/ic_volume_off.xml deleted file mode 100644 index a2cabcee0..000000000 --- a/app/src/main/res/drawable-night/ic_volume_off.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable-night/ic_volume_up.xml b/app/src/main/res/drawable-night/ic_volume_up.xml deleted file mode 100644 index 5d604f823..000000000 --- a/app/src/main/res/drawable-night/ic_volume_up.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_watch_later.xml b/app/src/main/res/drawable-night/ic_watch_later.xml deleted file mode 100644 index ff93ce2d7..000000000 --- a/app/src/main/res/drawable-night/ic_watch_later.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_wb_sunny.xml b/app/src/main/res/drawable-night/ic_wb_sunny.xml deleted file mode 100644 index 12a5d9774..000000000 --- a/app/src/main/res/drawable-night/ic_wb_sunny.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_whatshot.xml b/app/src/main/res/drawable-night/ic_whatshot.xml deleted file mode 100644 index 935ac8450..000000000 --- a/app/src/main/res/drawable-night/ic_whatshot.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable-night/ic_work.xml b/app/src/main/res/drawable-night/ic_work.xml deleted file mode 100644 index 8af0219f9..000000000 --- a/app/src/main/res/drawable-night/ic_work.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_add.xml b/app/src/main/res/drawable/ic_add.xml index fedd077d8..fc2163f43 100644 --- a/app/src/main/res/drawable/ic_add.xml +++ b/app/src/main/res/drawable/ic_add.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_add_circle_outline.xml b/app/src/main/res/drawable/ic_add_circle_outline.xml index 1596099f3..0d79d6918 100644 --- a/app/src/main/res/drawable/ic_add_circle_outline.xml +++ b/app/src/main/res/drawable/ic_add_circle_outline.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_apps.xml b/app/src/main/res/drawable/ic_apps.xml index b8c4ab12e..b800b1743 100644 --- a/app/src/main/res/drawable/ic_apps.xml +++ b/app/src/main/res/drawable/ic_apps.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml index 2d68f797b..5ed19d5fd 100644 --- a/app/src/main/res/drawable/ic_arrow_back.xml +++ b/app/src/main/res/drawable/ic_arrow_back.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_arrow_drop_down.xml b/app/src/main/res/drawable/ic_arrow_drop_down.xml index 270637216..da5d30807 100644 --- a/app/src/main/res/drawable/ic_arrow_drop_down.xml +++ b/app/src/main/res/drawable/ic_arrow_drop_down.xml @@ -1,9 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_arrow_drop_up.xml b/app/src/main/res/drawable/ic_arrow_drop_up.xml index fdc9dcf8d..df4199d18 100644 --- a/app/src/main/res/drawable/ic_arrow_drop_up.xml +++ b/app/src/main/res/drawable/ic_arrow_drop_up.xml @@ -1,9 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_art_track.xml b/app/src/main/res/drawable/ic_art_track.xml index abfdc203a..7e61e1044 100644 --- a/app/src/main/res/drawable/ic_art_track.xml +++ b/app/src/main/res/drawable/ic_art_track.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app/src/main/res/drawable/ic_asterisk.xml b/app/src/main/res/drawable/ic_asterisk.xml index 840682fee..df7c4b32c 100644 --- a/app/src/main/res/drawable/ic_asterisk.xml +++ b/app/src/main/res/drawable/ic_asterisk.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_attach_money.xml b/app/src/main/res/drawable/ic_attach_money.xml index dd93a7599..b2c0f5c36 100644 --- a/app/src/main/res/drawable/ic_attach_money.xml +++ b/app/src/main/res/drawable/ic_attach_money.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_backup.xml b/app/src/main/res/drawable/ic_backup.xml index 200bb7081..cf996d197 100644 --- a/app/src/main/res/drawable/ic_backup.xml +++ b/app/src/main/res/drawable/ic_backup.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_bookmark.xml b/app/src/main/res/drawable/ic_bookmark.xml index 5bf2e951c..32cd107f7 100644 --- a/app/src/main/res/drawable/ic_bookmark.xml +++ b/app/src/main/res/drawable/ic_bookmark.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_brightness_high.xml b/app/src/main/res/drawable/ic_brightness_high.xml index 1ff2d2e26..d613ed523 100644 --- a/app/src/main/res/drawable/ic_brightness_high.xml +++ b/app/src/main/res/drawable/ic_brightness_high.xml @@ -1,9 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_brightness_low.xml b/app/src/main/res/drawable/ic_brightness_low.xml index 1a00ce2dd..498a67ec0 100644 --- a/app/src/main/res/drawable/ic_brightness_low.xml +++ b/app/src/main/res/drawable/ic_brightness_low.xml @@ -1,9 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_brightness_medium.xml b/app/src/main/res/drawable/ic_brightness_medium.xml index 853e219bd..1f3952586 100644 --- a/app/src/main/res/drawable/ic_brightness_medium.xml +++ b/app/src/main/res/drawable/ic_brightness_medium.xml @@ -1,9 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_bug_report.xml b/app/src/main/res/drawable/ic_bug_report.xml index 206702ff2..c7c44ccb2 100644 --- a/app/src/main/res/drawable/ic_bug_report.xml +++ b/app/src/main/res/drawable/ic_bug_report.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_campaign.xml b/app/src/main/res/drawable/ic_campaign.xml index 4a0e2ddbb..a368f50f6 100644 --- a/app/src/main/res/drawable/ic_campaign.xml +++ b/app/src/main/res/drawable/ic_campaign.xml @@ -1,6 +1,7 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable-night/ic_drag_handle.xml b/app/src/main/res/drawable/ic_checklist.xml similarity index 50% rename from app/src/main/res/drawable-night/ic_drag_handle.xml rename to app/src/main/res/drawable/ic_checklist.xml index a6d3b5270..27bed183f 100644 --- a/app/src/main/res/drawable-night/ic_drag_handle.xml +++ b/app/src/main/res/drawable/ic_checklist.xml @@ -1,10 +1,10 @@ + android:pathData="M22,7h-9v2h9V7zM22,15h-9v2h9V15zM5.54,11L2,7.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,11zM5.54,19L2,15.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,19z" /> diff --git a/app/src/main/res/drawable/ic_child_care.xml b/app/src/main/res/drawable/ic_child_care.xml index 25a51bb23..5d2ac1665 100644 --- a/app/src/main/res/drawable/ic_child_care.xml +++ b/app/src/main/res/drawable/ic_child_care.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml index f50fd991b..1d5133364 100644 --- a/app/src/main/res/drawable/ic_close.xml +++ b/app/src/main/res/drawable/ic_close.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_cloud_download.xml b/app/src/main/res/drawable/ic_cloud_download.xml index aa051b25d..79c7db8e3 100644 --- a/app/src/main/res/drawable/ic_cloud_download.xml +++ b/app/src/main/res/drawable/ic_cloud_download.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_comment.xml b/app/src/main/res/drawable/ic_comment.xml index 7361b7fa6..4bc124a81 100644 --- a/app/src/main/res/drawable/ic_comment.xml +++ b/app/src/main/res/drawable/ic_comment.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app/src/main/res/drawable/ic_computer.xml b/app/src/main/res/drawable/ic_computer.xml index 04eb86a51..6b0e79313 100644 --- a/app/src/main/res/drawable/ic_computer.xml +++ b/app/src/main/res/drawable/ic_computer.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_crop_portrait.xml b/app/src/main/res/drawable/ic_crop_portrait.xml index d906df150..50ce52f91 100644 --- a/app/src/main/res/drawable/ic_crop_portrait.xml +++ b/app/src/main/res/drawable/ic_crop_portrait.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_delete.xml b/app/src/main/res/drawable/ic_delete.xml index 962e03374..f38c5f130 100644 --- a/app/src/main/res/drawable/ic_delete.xml +++ b/app/src/main/res/drawable/ic_delete.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_description.xml b/app/src/main/res/drawable/ic_description.xml index e7ef3d4b5..5b80cbefd 100644 --- a/app/src/main/res/drawable/ic_description.xml +++ b/app/src/main/res/drawable/ic_description.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app/src/main/res/drawable/ic_directions_bike.xml b/app/src/main/res/drawable/ic_directions_bike.xml index 328fbe393..b5580ee8d 100644 --- a/app/src/main/res/drawable/ic_directions_bike.xml +++ b/app/src/main/res/drawable/ic_directions_bike.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_directions_car.xml b/app/src/main/res/drawable/ic_directions_car.xml index b2fe8bdbd..3bfd9b4c3 100644 --- a/app/src/main/res/drawable/ic_directions_car.xml +++ b/app/src/main/res/drawable/ic_directions_car.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_done.xml b/app/src/main/res/drawable/ic_done.xml index bda675f14..43b77a9cd 100644 --- a/app/src/main/res/drawable/ic_done.xml +++ b/app/src/main/res/drawable/ic_done.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_drag_handle.xml b/app/src/main/res/drawable/ic_drag_handle.xml index 416631324..c08695e98 100644 --- a/app/src/main/res/drawable/ic_drag_handle.xml +++ b/app/src/main/res/drawable/ic_drag_handle.xml @@ -1,6 +1,7 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_explore.xml b/app/src/main/res/drawable/ic_explore.xml index e94079fed..2b974c69f 100644 --- a/app/src/main/res/drawable/ic_explore.xml +++ b/app/src/main/res/drawable/ic_explore.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_fast_forward.xml b/app/src/main/res/drawable/ic_fast_forward.xml index ab5ae6c37..4edc96a9b 100644 --- a/app/src/main/res/drawable/ic_fast_forward.xml +++ b/app/src/main/res/drawable/ic_fast_forward.xml @@ -1,9 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_fast_rewind.xml b/app/src/main/res/drawable/ic_fast_rewind.xml index ccc072158..33d9f56ef 100644 --- a/app/src/main/res/drawable/ic_fast_rewind.xml +++ b/app/src/main/res/drawable/ic_fast_rewind.xml @@ -1,9 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_fastfood.xml b/app/src/main/res/drawable/ic_fastfood.xml index 4d43eafd2..b2a1abdf3 100644 --- a/app/src/main/res/drawable/ic_fastfood.xml +++ b/app/src/main/res/drawable/ic_fastfood.xml @@ -1,9 +1,10 @@ diff --git a/app/src/main/res/drawable/ic_favorite.xml b/app/src/main/res/drawable/ic_favorite.xml index 17cea9270..87d14880f 100644 --- a/app/src/main/res/drawable/ic_favorite.xml +++ b/app/src/main/res/drawable/ic_favorite.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_file_download.xml b/app/src/main/res/drawable/ic_file_download.xml index 370bba93d..b4d9e15e9 100644 --- a/app/src/main/res/drawable/ic_file_download.xml +++ b/app/src/main/res/drawable/ic_file_download.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_filter_list.xml b/app/src/main/res/drawable/ic_filter_list.xml index 6826b3d5a..e1a2b236b 100644 --- a/app/src/main/res/drawable/ic_filter_list.xml +++ b/app/src/main/res/drawable/ic_filter_list.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_fitness_center.xml b/app/src/main/res/drawable/ic_fitness_center.xml index 3e2425e40..56670cba6 100644 --- a/app/src/main/res/drawable/ic_fitness_center.xml +++ b/app/src/main/res/drawable/ic_fitness_center.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_format_list_numbered.xml b/app/src/main/res/drawable/ic_format_list_numbered.xml index 429616ec9..b11666c56 100644 --- a/app/src/main/res/drawable/ic_format_list_numbered.xml +++ b/app/src/main/res/drawable/ic_format_list_numbered.xml @@ -1,7 +1,7 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_fullscreen_exit.xml b/app/src/main/res/drawable/ic_fullscreen_exit.xml index a940aa13c..a497da742 100644 --- a/app/src/main/res/drawable/ic_fullscreen_exit.xml +++ b/app/src/main/res/drawable/ic_fullscreen_exit.xml @@ -1,9 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_headset.xml b/app/src/main/res/drawable/ic_headset.xml index 674aa8def..3eff4b7dd 100644 --- a/app/src/main/res/drawable/ic_headset.xml +++ b/app/src/main/res/drawable/ic_headset.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_heart.xml b/app/src/main/res/drawable/ic_heart.xml index 86d1f0527..248f9788b 100644 --- a/app/src/main/res/drawable/ic_heart.xml +++ b/app/src/main/res/drawable/ic_heart.xml @@ -2,9 +2,9 @@ android:width="24dp" android:height="24dp" android:tint="#E53935" - android:viewportWidth="24.0" - android:viewportHeight="24.0"> + android:viewportWidth="24" + android:viewportHeight="24"> + android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z" /> diff --git a/app/src/main/res/drawable/ic_help.xml b/app/src/main/res/drawable/ic_help.xml index b1d7a2cf5..45955eae7 100644 --- a/app/src/main/res/drawable/ic_help.xml +++ b/app/src/main/res/drawable/ic_help.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_history.xml b/app/src/main/res/drawable/ic_history.xml index d9f75ea6d..4e21de19d 100644 --- a/app/src/main/res/drawable/ic_history.xml +++ b/app/src/main/res/drawable/ic_history.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml index f8bb0b556..48f968b4c 100644 --- a/app/src/main/res/drawable/ic_home.xml +++ b/app/src/main/res/drawable/ic_home.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_hourglass_top.xml b/app/src/main/res/drawable/ic_hourglass_top.xml index 59ad4b2d2..f92496779 100644 --- a/app/src/main/res/drawable/ic_hourglass_top.xml +++ b/app/src/main/res/drawable/ic_hourglass_top.xml @@ -1,9 +1,10 @@ diff --git a/app/src/main/res/drawable/ic_info_outline.xml b/app/src/main/res/drawable/ic_info_outline.xml index 6c6060619..3bbe51917 100644 --- a/app/src/main/res/drawable/ic_info_outline.xml +++ b/app/src/main/res/drawable/ic_info_outline.xml @@ -1,6 +1,7 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_language.xml b/app/src/main/res/drawable/ic_language.xml index 340a4bf0f..8bc821acc 100644 --- a/app/src/main/res/drawable/ic_language.xml +++ b/app/src/main/res/drawable/ic_language.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_list.xml b/app/src/main/res/drawable/ic_list.xml index 1471c52f5..f6538e875 100644 --- a/app/src/main/res/drawable/ic_list.xml +++ b/app/src/main/res/drawable/ic_list.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_live_tv.xml b/app/src/main/res/drawable/ic_live_tv.xml index 1f7957c4a..80fb172aa 100644 --- a/app/src/main/res/drawable/ic_live_tv.xml +++ b/app/src/main/res/drawable/ic_live_tv.xml @@ -1,5 +1,11 @@ - - + + diff --git a/app/src/main/res/drawable/ic_mic.xml b/app/src/main/res/drawable/ic_mic.xml index 8b765ffd4..9da90f5a9 100644 --- a/app/src/main/res/drawable/ic_mic.xml +++ b/app/src/main/res/drawable/ic_mic.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_more_vert.xml b/app/src/main/res/drawable/ic_more_vert.xml index 7b7f19554..1a873cf8b 100644 --- a/app/src/main/res/drawable/ic_more_vert.xml +++ b/app/src/main/res/drawable/ic_more_vert.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_motorcycle.xml b/app/src/main/res/drawable/ic_motorcycle.xml index e354f8bda..7684b0673 100644 --- a/app/src/main/res/drawable/ic_motorcycle.xml +++ b/app/src/main/res/drawable/ic_motorcycle.xml @@ -1,6 +1,7 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_music_note.xml b/app/src/main/res/drawable/ic_music_note.xml index 830a7fab1..cc4e5bd10 100644 --- a/app/src/main/res/drawable/ic_music_note.xml +++ b/app/src/main/res/drawable/ic_music_note.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_next.xml b/app/src/main/res/drawable/ic_next.xml index 9f3462892..2805ebb26 100644 --- a/app/src/main/res/drawable/ic_next.xml +++ b/app/src/main/res/drawable/ic_next.xml @@ -1,10 +1,9 @@ - diff --git a/app/src/main/res/drawable/ic_notifications.xml b/app/src/main/res/drawable/ic_notifications.xml new file mode 100644 index 000000000..f87cac524 --- /dev/null +++ b/app/src/main/res/drawable/ic_notifications.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_palette.xml b/app/src/main/res/drawable/ic_palette.xml index 568f8e4de..0356bfe8f 100644 --- a/app/src/main/res/drawable/ic_palette.xml +++ b/app/src/main/res/drawable/ic_palette.xml @@ -1,6 +1,7 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_people.xml b/app/src/main/res/drawable/ic_people.xml index 603c006db..9cd3ad3fb 100644 --- a/app/src/main/res/drawable/ic_people.xml +++ b/app/src/main/res/drawable/ic_people.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml index 55495d5a0..db64734ae 100644 --- a/app/src/main/res/drawable/ic_person.xml +++ b/app/src/main/res/drawable/ic_person.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_pets.xml b/app/src/main/res/drawable/ic_pets.xml index 58e52bf6c..0aadab03d 100644 --- a/app/src/main/res/drawable/ic_pets.xml +++ b/app/src/main/res/drawable/ic_pets.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_picture_in_picture.xml b/app/src/main/res/drawable/ic_picture_in_picture.xml index 326ff0304..91fd52413 100644 --- a/app/src/main/res/drawable/ic_picture_in_picture.xml +++ b/app/src/main/res/drawable/ic_picture_in_picture.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_pin.xml b/app/src/main/res/drawable/ic_pin.xml index 70578fbeb..e41fd7f12 100644 --- a/app/src/main/res/drawable/ic_pin.xml +++ b/app/src/main/res/drawable/ic_pin.xml @@ -1,6 +1,7 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_play_seek_triangle.xml b/app/src/main/res/drawable/ic_play_seek_triangle.xml index 1aee026db..9c257c423 100644 --- a/app/src/main/res/drawable/ic_play_seek_triangle.xml +++ b/app/src/main/res/drawable/ic_play_seek_triangle.xml @@ -1,11 +1,9 @@ - + android:width="16dp" + android:height="20dp" + android:viewportWidth="24" + android:viewportHeight="24"> - + android:fillColor="#FFFFFF" + android:pathData="M3,2 L22,12 L3,22 Z" /> diff --git a/app/src/main/res/drawable/ic_playlist_add.xml b/app/src/main/res/drawable/ic_playlist_add.xml index 341894ba9..144f123b1 100644 --- a/app/src/main/res/drawable/ic_playlist_add.xml +++ b/app/src/main/res/drawable/ic_playlist_add.xml @@ -1,6 +1,7 @@ - diff --git a/app/src/main/res/drawable/ic_public.xml b/app/src/main/res/drawable/ic_public.xml index 192884570..796f37812 100644 --- a/app/src/main/res/drawable/ic_public.xml +++ b/app/src/main/res/drawable/ic_public.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_radio.xml b/app/src/main/res/drawable/ic_radio.xml index ca4501bb7..f009ff54e 100644 --- a/app/src/main/res/drawable/ic_radio.xml +++ b/app/src/main/res/drawable/ic_radio.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml index 1f9072a36..20af23dde 100644 --- a/app/src/main/res/drawable/ic_refresh.xml +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_repeat.xml b/app/src/main/res/drawable/ic_repeat.xml index 24d9f44f0..fb9ef820b 100644 --- a/app/src/main/res/drawable/ic_repeat.xml +++ b/app/src/main/res/drawable/ic_repeat.xml @@ -1,9 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_replay.xml b/app/src/main/res/drawable/ic_replay.xml index d00231b51..987710fc7 100644 --- a/app/src/main/res/drawable/ic_replay.xml +++ b/app/src/main/res/drawable/ic_replay.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_restaurant.xml b/app/src/main/res/drawable/ic_restaurant.xml index 51f1145c6..9dccc8ee7 100644 --- a/app/src/main/res/drawable/ic_restaurant.xml +++ b/app/src/main/res/drawable/ic_restaurant.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_rss_feed.xml b/app/src/main/res/drawable/ic_rss_feed.xml index ed6228cc2..a73eff527 100644 --- a/app/src/main/res/drawable/ic_rss_feed.xml +++ b/app/src/main/res/drawable/ic_rss_feed.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_save.xml b/app/src/main/res/drawable/ic_save.xml index 0651fcc6c..26e664589 100644 --- a/app/src/main/res/drawable/ic_save.xml +++ b/app/src/main/res/drawable/ic_save.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_school.xml b/app/src/main/res/drawable/ic_school.xml index 54dc17ddb..6d7e2f0e9 100644 --- a/app/src/main/res/drawable/ic_school.xml +++ b/app/src/main/res/drawable/ic_school.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml index d23ea57f8..a889b09e5 100644 --- a/app/src/main/res/drawable/ic_search.xml +++ b/app/src/main/res/drawable/ic_search.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_search_add.xml b/app/src/main/res/drawable/ic_search_add.xml index 889ea4c6f..449115e3a 100644 --- a/app/src/main/res/drawable/ic_search_add.xml +++ b/app/src/main/res/drawable/ic_search_add.xml @@ -1,6 +1,7 @@ - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml index e50f6fe3a..1e259c6ad 100644 --- a/app/src/main/res/drawable/ic_settings.xml +++ b/app/src/main/res/drawable/ic_settings.xml @@ -1,6 +1,7 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml index 338d95ad5..40971e408 100644 --- a/app/src/main/res/drawable/ic_share.xml +++ b/app/src/main/res/drawable/ic_share.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_shopping_cart.xml b/app/src/main/res/drawable/ic_shopping_cart.xml index 18e1b930d..9e361b60d 100644 --- a/app/src/main/res/drawable/ic_shopping_cart.xml +++ b/app/src/main/res/drawable/ic_shopping_cart.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_shuffle.xml b/app/src/main/res/drawable/ic_shuffle.xml index 1192dec9f..86717de36 100644 --- a/app/src/main/res/drawable/ic_shuffle.xml +++ b/app/src/main/res/drawable/ic_shuffle.xml @@ -1,9 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_sort.xml b/app/src/main/res/drawable/ic_sort.xml index b537e982e..a97bebd87 100644 --- a/app/src/main/res/drawable/ic_sort.xml +++ b/app/src/main/res/drawable/ic_sort.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_stars.xml b/app/src/main/res/drawable/ic_stars.xml index 35957427d..ac5b9dd19 100644 --- a/app/src/main/res/drawable/ic_stars.xml +++ b/app/src/main/res/drawable/ic_stars.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_subtitles.xml b/app/src/main/res/drawable/ic_subtitles.xml index 1d997a032..43bf3e16b 100644 --- a/app/src/main/res/drawable/ic_subtitles.xml +++ b/app/src/main/res/drawable/ic_subtitles.xml @@ -1,9 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_telescope.xml b/app/src/main/res/drawable/ic_telescope.xml index 8077e9325..e3d5ea33b 100644 --- a/app/src/main/res/drawable/ic_telescope.xml +++ b/app/src/main/res/drawable/ic_telescope.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_thumb_down.xml b/app/src/main/res/drawable/ic_thumb_down.xml index 103e5fea3..aa828aa50 100644 --- a/app/src/main/res/drawable/ic_thumb_down.xml +++ b/app/src/main/res/drawable/ic_thumb_down.xml @@ -1,6 +1,7 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_tv.xml b/app/src/main/res/drawable/ic_tv.xml index 11d2d25b6..91d860eaf 100644 --- a/app/src/main/res/drawable/ic_tv.xml +++ b/app/src/main/res/drawable/ic_tv.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_videogame_asset.xml b/app/src/main/res/drawable/ic_videogame_asset.xml index 02fa7eb56..01a91b053 100644 --- a/app/src/main/res/drawable/ic_videogame_asset.xml +++ b/app/src/main/res/drawable/ic_videogame_asset.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_visibility_off.xml b/app/src/main/res/drawable/ic_visibility_off.xml index e0b170300..f833d5e06 100644 --- a/app/src/main/res/drawable/ic_visibility_off.xml +++ b/app/src/main/res/drawable/ic_visibility_off.xml @@ -1,9 +1,10 @@ + android:width="24dp" + android:height="24dp" + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> + android:fillColor="#FF000000" + android:pathData="M12,7c2.76,0 5,2.24 5,5 0,0.65 -0.13,1.26 -0.36,1.83l2.92,2.92c1.51,-1.26 2.7,-2.89 3.43,-4.75 -1.73,-4.39 -6,-7.5 -11,-7.5 -1.4,0 -2.74,0.25 -3.98,0.7l2.16,2.16C10.74,7.13 11.35,7 12,7zM2,4.27l2.28,2.28 0.46,0.46C3.08,8.3 1.78,10.02 1,12c1.73,4.39 6,7.5 11,7.5 1.55,0 3.03,-0.3 4.38,-0.84l0.42,0.42L19.73,22 21,20.73 3.27,3 2,4.27zM7.53,9.8l1.55,1.55c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.66 1.34,3 3,3 0.22,0 0.44,-0.03 0.65,-0.08l1.55,1.55c-0.67,0.33 -1.41,0.53 -2.2,0.53 -2.76,0 -5,-2.24 -5,-5 0,-0.79 0.2,-1.53 0.53,-2.2zM11.84,9.02l3.15,3.15 0.02,-0.16c0,-1.66 -1.34,-3 -3,-3l-0.17,0.01z" /> diff --git a/app/src/main/res/drawable/ic_visibility_on.xml b/app/src/main/res/drawable/ic_visibility_on.xml index 6c95a5d29..06e530961 100644 --- a/app/src/main/res/drawable/ic_visibility_on.xml +++ b/app/src/main/res/drawable/ic_visibility_on.xml @@ -1,9 +1,10 @@ + android:width="24dp" + android:height="24dp" + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> + android:fillColor="#FF000000" + android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z" /> diff --git a/app/src/main/res/drawable/ic_volume_down.xml b/app/src/main/res/drawable/ic_volume_down.xml index bcc363279..0fe36fad3 100644 --- a/app/src/main/res/drawable/ic_volume_down.xml +++ b/app/src/main/res/drawable/ic_volume_down.xml @@ -1,9 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_volume_mute.xml b/app/src/main/res/drawable/ic_volume_mute.xml index 2c9151396..b18f6337c 100644 --- a/app/src/main/res/drawable/ic_volume_mute.xml +++ b/app/src/main/res/drawable/ic_volume_mute.xml @@ -1,9 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_volume_off.xml b/app/src/main/res/drawable/ic_volume_off.xml index 7700239a3..420593e04 100644 --- a/app/src/main/res/drawable/ic_volume_off.xml +++ b/app/src/main/res/drawable/ic_volume_off.xml @@ -1,10 +1,10 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> - \ No newline at end of file + diff --git a/app/src/main/res/drawable/ic_volume_up.xml b/app/src/main/res/drawable/ic_volume_up.xml index aaaf84983..b5a47789b 100644 --- a/app/src/main/res/drawable/ic_volume_up.xml +++ b/app/src/main/res/drawable/ic_volume_up.xml @@ -1,9 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_watch_later.xml b/app/src/main/res/drawable/ic_watch_later.xml index 72952bcaa..34ecad214 100644 --- a/app/src/main/res/drawable/ic_watch_later.xml +++ b/app/src/main/res/drawable/ic_watch_later.xml @@ -1,6 +1,7 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_whatshot.xml b/app/src/main/res/drawable/ic_whatshot.xml index 07965067e..84260ffe4 100644 --- a/app/src/main/res/drawable/ic_whatshot.xml +++ b/app/src/main/res/drawable/ic_whatshot.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_work.xml b/app/src/main/res/drawable/ic_work.xml index 2ee55ea23..014718e60 100644 --- a/app/src/main/res/drawable/ic_work.xml +++ b/app/src/main/res/drawable/ic_work.xml @@ -1,8 +1,9 @@ + android:tint="@color/defaultIconTint" + android:viewportWidth="24" + android:viewportHeight="24"> diff --git a/app/src/main/res/layout-large-land/fragment_video_detail.xml b/app/src/main/res/layout-large-land/fragment_video_detail.xml index 1ee11c49b..851085b5b 100644 --- a/app/src/main/res/layout-large-land/fragment_video_detail.xml +++ b/app/src/main/res/layout-large-land/fragment_video_detail.xml @@ -266,14 +266,15 @@ android:layout_width="wrap_content" android:layout_height="wrap_content"> - + android:src="@drawable/buddy" + app:shapeAppearance="@style/CircularImageView" /> - diff --git a/app/src/main/res/layout-large-land/player.xml b/app/src/main/res/layout-large-land/player.xml deleted file mode 100644 index 71a325cf3..000000000 --- a/app/src/main/res/layout-large-land/player.xml +++ /dev/null @@ -1,756 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -