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/README.md b/README.md index 99d39cf3d..ae6bc090d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# NewPipe x SponsorBlock -A fork of [NewPipe](https://github.com/TeamNewPipe/NewPipe) with [SponsorBlock](https://sponsor.ajay.app/) functionality. +# NewPipe x SponsorBlock x Return YouTube Dislike +A fork of [NewPipe](https://github.com/TeamNewPipe/NewPipe) with [SponsorBlock](https://sponsor.ajay.app/) and [Return YouTube Dislike](https://returnyoutubedislike.com/) functionality. ![01](.github/images/preview01.gif) ![02](.github/images/preview02.gif) @@ -12,9 +12,9 @@ The implementation is still a bit basic but it generally works pretty well. Builds will be uploaded in the [Releases](https://github.com/polymorphicshade/NewPipe/releases) section. Please download the APK from the newest release and install it on your device. ## Why isn't this in upstream NewPipe? -[The developer team](https://github.com/TeamNewPipe) behind the official NewPipe decided that they do not want to include this kind of functionality in their app. See https://newpipe.schabi.org/blog/pinned/newpipe-and-online-advertising/ and https://github.com/TeamNewPipe/NewPipe/pull/3205 for more information and discussion. +[The developer team](https://github.com/TeamNewPipe) behind the official NewPipe decided that they do not want to include these kinds of functionality in their app. See https://newpipe.schabi.org/blog/pinned/newpipe-and-online-advertising/, https://github.com/TeamNewPipe/NewPipe/pull/3205, and https://github.com/TeamNewPipe/NewPipe/issues/7469 for more information and discussion. -We obviously disagree but we respect their decision and continue to offer SponsorBlock in NewPipe via this fork. +We obviously disagree but we respect their decision and continue to offer SponsorBlock and Return YouTube Dislike in NewPipe via this fork. ## Bugs Please do not report bugs encountered while using this fork to the upstream developers. Either try to reproduce the bug in vanilla NewPipe and then report it (preferred) or [create a bug report in our repo](https://github.com/polymorphicshade/NewPipe/issues/new?assignees=&labels=bug&template=bug_report.md). diff --git a/app/build.gradle b/app/build.gradle index 027190003..85079604f 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.polymorphicshade.newpipe" resValue "string", "app_name", "NewPipe SponsorBlock" minSdk 19 targetSdk 29 - versionCode 984 - versionName "0.22.1" + versionCode 987 + versionName "0.23.1" multiDexEnabled true @@ -98,15 +98,16 @@ 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' + groupieVersion = '2.10.1' markwonVersion = '4.6.2' leakCanaryVersion = '2.5' @@ -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:v0.21.14' + implementation 'com.github.TeamNewPipe:NewPipeExtractor:5219a705bab539cf8c6624d0cec216e76e85f0b1' /** 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,9 @@ 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 "androidx.work:work-runtime-ktx:${androidxWorkVersion}" + implementation "androidx.work:work-rxjava3:${androidxWorkVersion}" + implementation 'com.google.android.material:material:1.5.0' /** Third-party libraries **/ // Instance state boilerplate elimination @@ -246,8 +249,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" @@ -260,7 +261,7 @@ dependencies { implementation "com.nononsenseapps:filepicker:4.2.1" // Crash reporting - implementation "ch.acra:acra-core:5.8.4" + implementation "ch.acra:acra-core:5.9.3" // Properly restarting implementation 'com.jakewharton:process-phoenix:2.1.2' 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..016feb576 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt @@ -0,0 +1,214 @@ +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.Builder() + .setContent("https://example.com", true) + .setMediaFormat(MediaFormat.SRT) + .setLanguageCode("pt-BR") + .setAutoGenerated(false) + .build() + }, + 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.Builder() + .setId(Stream.ID_UNKNOWN) + .setContent("https://example.com/$it", true) + .setMediaFormat(MediaFormat.OPUS) + .setAverageBitrate(192) + .build() + }, + 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.Builder() + .setId(Stream.ID_UNKNOWN) + .setContent("https://example.com", true) + .setMediaFormat(MediaFormat.MPEG_4) + .setResolution("720p") + .setIsVideoOnly(it) + .build() + }, + 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.Builder() + .setId(Stream.ID_UNKNOWN) + .setContent("https://example.com", true) + .setMediaFormat(MediaFormat.OPUS) + .setAverageBitrate(192) + .build() + } 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 bb22e71a9..fc197067a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -386,9 +386,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..70c947478 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; @@ -205,7 +205,7 @@ public class App extends MultiDexApplication { return; } - final CoreConfigurationBuilder acraConfig = new CoreConfigurationBuilder(this) + final CoreConfigurationBuilder acraConfig = new CoreConfigurationBuilder() .withBuildConfigClass(BuildConfig.class); ACRA.init(this, acraConfig); } @@ -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 776553b61..000000000 --- a/app/src/main/java/org/schabi/newpipe/CheckForNewAppVersion.java +++ /dev/null @@ -1,147 +0,0 @@ -package org.schabi.newpipe; - -import android.app.Application; -import android.app.PendingIntent; -import android.content.Intent; -import android.content.SharedPreferences; -import android.net.ConnectivityManager; -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.ContextCompat; -import androidx.preference.PreferenceManager; - -import com.grack.nanojson.JsonObject; -import com.grack.nanojson.JsonParser; -import com.grack.nanojson.JsonParserException; - -import org.schabi.newpipe.util.Version; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Maybe; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public final class CheckForNewAppVersion { - private CheckForNewAppVersion() { - } - - private static final boolean DEBUG = MainActivity.DEBUG; - private static final String TAG = CheckForNewAppVersion.class.getSimpleName(); - private static final String API_URL = - "https://api.github.com/repos/polymorphicshade/NewPipe/releases/latest"; - - /** - * 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 - */ - private static void compareAppVersionAndShowNotification(@NonNull final Application application, - final String versionName, - final String apkLocationUrl) { - final Version sourceVersion = Version.fromString(BuildConfig.VERSION_NAME); - final Version targetVersion = Version.fromString(versionName); - - // abort if source version is the same or newer than target version - if (sourceVersion.compareTo(targetVersion) >= 0) { - return; - } - - final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkLocationUrl)); - 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) { - // TODO: hope this isn't gonna be an issue - //return getCertificateSHA1Fingerprint(app).equals(RELEASE_CERT_PUBLIC_KEY_SHA1); - - return true; - } - - private static boolean isConnected(@NonNull final App app) { - final ConnectivityManager connectivityManager = - ContextCompat.getSystemService(app, ConnectivityManager.class); - return connectivityManager != null && connectivityManager.getActiveNetworkInfo() != null - && connectivityManager.getActiveNetworkInfo().isConnected(); - } - - @Nullable - public static Disposable checkNewVersion() { - final App app = App.getApp(); - - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app); - - // Check if user has enabled/disabled update checking - // and if the current apk is a github one or not. - if (!prefs.getBoolean(app.getString(R.string.update_app_key), true)) { - return null; - } - - return Maybe - .fromCallable(() -> { - if (!isConnected(app)) { - return null; - } - - // Make a network request to get latest NewPipe data. - return DownloaderImpl.getInstance().get(API_URL).responseBody(); - }) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - response -> { - // Parse the json from the response. - try { - // assuming the first result is the latest one - final JsonObject jObj = JsonParser.object().from(response); - - final String versionName = jObj.getString("tag_name"); - - final String apkLocationUrl = jObj - .getArray("assets") - .getObject(0) - .getString("browser_download_url"); - - compareAppVersionAndShowNotification(app, versionName, - apkLocationUrl); - } catch (final JsonParserException e) { - // connectivity problems, do not alarm user and fail silently - if (DEBUG) { - Log.w(TAG, "Could not get Github API: invalid json", e); - } - } - }, - e -> { - // connectivity problems, do not alarm user and fail silently - if (DEBUG) { - Log.w(TAG, "Could not get Github API: network problem", e); - } - }); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java b/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java index 5605d031d..f6bb14b31 100644 --- a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java +++ b/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java @@ -43,7 +43,7 @@ import static org.schabi.newpipe.MainActivity.DEBUG; public final class DownloaderImpl extends Downloader { public static final String USER_AGENT - = "Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.0"; + = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"; public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY = "youtube_restricted_mode_key"; public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000"; diff --git a/app/src/main/java/org/schabi/newpipe/LocalPlayerActivity.java b/app/src/main/java/org/schabi/newpipe/LocalPlayerActivity.java index fbed1f33d..dc7cc7e77 100644 --- a/app/src/main/java/org/schabi/newpipe/LocalPlayerActivity.java +++ b/app/src/main/java/org/schabi/newpipe/LocalPlayerActivity.java @@ -29,7 +29,7 @@ import org.schabi.newpipe.util.VideoSegment; import java.util.ArrayList; import java.util.List; -public class LocalPlayerActivity extends AppCompatActivity implements Player.EventListener, +public class LocalPlayerActivity extends AppCompatActivity implements Player.Listener, LocalPlayerListener, PlaybackParameterDialog.Callback { private LocalPlayer localPlayer; private PlayerView playerView; diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index 5adfcd2f6..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.checkNewVersion; 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,20 +159,28 @@ 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 protected void onPostCreate(final Bundle savedInstanceState) { super.onPostCreate(savedInstanceState); - // Start the service 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. - checkNewVersion(); + + final App app = App.getApp(); + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app); + + if (prefs.getBoolean(app.getString(R.string.update_app_key), true)) { + // Start the worker which is checking all conditions + // and eventually searching for a new version. + NewVersionWorker.enqueueNewVersionCheckingWork(app); + } } private void setupDrawer() throws ExtractionException { @@ -221,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++; } @@ -713,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..b9d1fe8f9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt @@ -0,0 +1,166 @@ +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 org.schabi.newpipe.util.Version +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 + */ + private fun compareAppVersionAndShowNotification( + versionName: String, + apkLocationUrl: String? + ) { + val sourceVersion = Version.fromString(BuildConfig.VERSION_NAME) + val targetVersion = Version.fromString(versionName) + + // abort if source version is the same or newer than target version + if (sourceVersion >= targetVersion) { + return + } + + val app = App.getApp() + + 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(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 jObj = JsonParser.`object`().from(response.responseBody()) + val versionName = jObj.getString("tag_name") + val apkLocationUrl = jObj + .getArray("assets") + .getObject(0) + .getString("browser_download_url") + compareAppVersionAndShowNotification(versionName, apkLocationUrl) + } catch (e: JsonParserException) { + // Most likely something is wrong in data received from API_URL. + // Do not alarm user and fail silently. + if (DEBUG) { + Log.w(TAG, "Could not get Github 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 API_URL = + "https://api.github.com/repos/polymorphicshade/NewPipe/releases/latest" + + /** + * 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..1fe6ce7ec 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -24,12 +24,12 @@ import android.widget.Toast; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.content.res.AppCompatResources; import androidx.core.app.NotificationCompat; import androidx.core.app.ServiceCompat; -import androidx.core.widget.TextViewCompat; import androidx.fragment.app.FragmentManager; import androidx.preference.PreferenceManager; @@ -58,7 +58,6 @@ import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException; import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.player.MainPlayer; @@ -71,7 +70,7 @@ import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.ListHelper; +import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.ThemeHelper; @@ -127,8 +126,10 @@ public class RouterActivity extends AppCompatActivity { } } + ThemeHelper.setDayNightMode(this); setTheme(ThemeHelper.isLightThemeSelected(this) ? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark); + Localization.assureCorrectAppLanguage(this); } @Override @@ -257,80 +258,122 @@ public class RouterActivity extends AppCompatActivity { protected void onSuccess() { final SharedPreferences preferences = PreferenceManager .getDefaultSharedPreferences(this); - final String selectedChoiceKey = preferences - .getString(getString(R.string.preferred_open_action_key), - getString(R.string.preferred_open_action_default)); - final String showInfoKey = getString(R.string.show_info_key); - final String videoPlayerKey = getString(R.string.video_player_key); - final String backgroundPlayerKey = getString(R.string.background_player_key); - final String popupPlayerKey = getString(R.string.popup_player_key); - final String downloadKey = getString(R.string.download_key); - final String alwaysAskKey = getString(R.string.always_ask_open_action_key); + final ChoiceAvailabilityChecker choiceChecker = new ChoiceAvailabilityChecker( + getChoicesForService(currentService, currentLinkType), + preferences.getString(getString(R.string.preferred_open_action_key), + getString(R.string.preferred_open_action_default))); - if (selectedChoiceKey.equals(alwaysAskKey)) { - final List choices - = getChoicesForService(currentService, currentLinkType); + // Check for non-player related choices + if (choiceChecker.isAvailableAndSelected( + R.string.show_info_key, + R.string.download_key, + R.string.add_to_playlist_key)) { + handleChoice(choiceChecker.getSelectedChoiceKey()); + return; + } + // Check if the choice is player related + if (choiceChecker.isAvailableAndSelected( + R.string.video_player_key, + R.string.background_player_key, + R.string.popup_player_key)) { + + final String selectedChoice = choiceChecker.getSelectedChoiceKey(); - switch (choices.size()) { - case 1: - handleChoice(choices.get(0).key); - break; - case 0: - handleChoice(showInfoKey); - break; - default: - showDialog(choices); - break; - } - } else if (selectedChoiceKey.equals(showInfoKey)) { - handleChoice(showInfoKey); - } else if (selectedChoiceKey.equals(downloadKey)) { - handleChoice(downloadKey); - } else { final boolean isExtVideoEnabled = preferences.getBoolean( getString(R.string.use_external_video_player_key), false); final boolean isExtAudioEnabled = preferences.getBoolean( getString(R.string.use_external_audio_player_key), false); - final boolean isVideoPlayerSelected = selectedChoiceKey.equals(videoPlayerKey) - || selectedChoiceKey.equals(popupPlayerKey); - final boolean isAudioPlayerSelected = selectedChoiceKey.equals(backgroundPlayerKey); + final boolean isVideoPlayerSelected = + selectedChoice.equals(getString(R.string.video_player_key)) + || selectedChoice.equals(getString(R.string.popup_player_key)); + final boolean isAudioPlayerSelected = + selectedChoice.equals(getString(R.string.background_player_key)); - if (currentLinkType != LinkType.STREAM) { - if (isExtAudioEnabled && isAudioPlayerSelected - || isExtVideoEnabled && isVideoPlayerSelected) { - Toast.makeText(this, R.string.external_player_unsupported_link_type, - Toast.LENGTH_LONG).show(); - handleChoice(showInfoKey); - return; - } + if (currentLinkType != LinkType.STREAM + && ((isExtAudioEnabled && isAudioPlayerSelected) + || (isExtVideoEnabled && isVideoPlayerSelected)) + ) { + Toast.makeText(this, R.string.external_player_unsupported_link_type, + Toast.LENGTH_LONG).show(); + handleChoice(getString(R.string.show_info_key)); + return; } - final List capabilities - = currentService.getServiceInfo().getMediaCapabilities(); + final List capabilities = + currentService.getServiceInfo().getMediaCapabilities(); - boolean serviceSupportsChoice = false; - if (isVideoPlayerSelected) { - serviceSupportsChoice = capabilities.contains(VIDEO); - } else if (selectedChoiceKey.equals(backgroundPlayerKey)) { - serviceSupportsChoice = capabilities.contains(AUDIO); - } - - if (serviceSupportsChoice) { - handleChoice(selectedChoiceKey); + // Check if the service supports the choice + if ((isVideoPlayerSelected && capabilities.contains(VIDEO)) + || (isAudioPlayerSelected && capabilities.contains(AUDIO))) { + handleChoice(selectedChoice); } else { - handleChoice(showInfoKey); + handleChoice(getString(R.string.show_info_key)); } + return; + } + + // Default / Ask always + final List availableChoices = choiceChecker.getAvailableChoices(); + switch (availableChoices.size()) { + case 1: + handleChoice(availableChoices.get(0).key); + break; + case 0: + handleChoice(getString(R.string.show_info_key)); + break; + default: + showDialog(availableChoices); + break; + } + } + + /** + * This is a helper class for checking if the choices are available and/or selected. + */ + class ChoiceAvailabilityChecker { + private final List availableChoices; + private final String selectedChoiceKey; + + ChoiceAvailabilityChecker( + @NonNull final List availableChoices, + @NonNull final String selectedChoiceKey) { + this.availableChoices = availableChoices; + this.selectedChoiceKey = selectedChoiceKey; + } + + public List getAvailableChoices() { + return availableChoices; + } + + public String getSelectedChoiceKey() { + return selectedChoiceKey; + } + + public boolean isAvailableAndSelected(@StringRes final int... wantedKeys) { + return Arrays.stream(wantedKeys).anyMatch(this::isAvailableAndSelected); + } + + public boolean isAvailableAndSelected(@StringRes final int wantedKey) { + final String wanted = getString(wantedKey); + // Check if the wanted option is selected + if (!selectedChoiceKey.equals(wanted)) { + return false; + } + // Check if it's available + return availableChoices.stream().anyMatch(item -> wanted.equals(item.key)); } } private void showDialog(final List choices) { final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); - final Context themeWrapperContext = getThemeWrapperContext(); - final LayoutInflater inflater = LayoutInflater.from(themeWrapperContext); - final RadioGroup radioGroup = SingleChoiceDialogViewBinding.inflate(getLayoutInflater()) - .list; + final Context themeWrapperContext = getThemeWrapperContext(); + final LayoutInflater layoutInflater = LayoutInflater.from(themeWrapperContext); + + final SingleChoiceDialogViewBinding binding = + SingleChoiceDialogViewBinding.inflate(layoutInflater); + final RadioGroup radioGroup = binding.list; final DialogInterface.OnClickListener dialogButtonsClickListener = (dialog, which) -> { final int indexOfChild = radioGroup.indexOfChild( @@ -349,21 +392,19 @@ public class RouterActivity extends AppCompatActivity { alertDialogChoice = new AlertDialog.Builder(themeWrapperContext) .setTitle(R.string.preferred_open_action_share_menu_title) - .setView(radioGroup) + .setView(binding.getRoot()) .setCancelable(true) .setNegativeButton(R.string.just_once, dialogButtonsClickListener) .setPositiveButton(R.string.always, dialogButtonsClickListener) - .setOnDismissListener((dialog) -> { + .setOnDismissListener(dialog -> { if (!selectionIsDownload && !selectionIsAddToPlaylist) { finish(); } }) .create(); - //noinspection CodeBlock2Expr - alertDialogChoice.setOnShowListener(dialog -> { - setDialogButtonsState(alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1); - }); + alertDialogChoice.setOnShowListener(dialog -> setDialogButtonsState( + alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1)); radioGroup.setOnCheckedChangeListener((group, checkedId) -> setDialogButtonsState(alertDialogChoice, true)); @@ -383,9 +424,10 @@ public class RouterActivity extends AppCompatActivity { int id = 12345; for (final AdapterChoiceItem item : choices) { - final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater).getRoot(); + final RadioButton radioButton = ListRadioIconItemBinding.inflate(layoutInflater) + .getRoot(); radioButton.setText(item.description); - TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(radioButton, + radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds( AppCompatResources.getDrawable(themeWrapperContext, item.icon), null, null, null); radioButton.setChecked(false); @@ -425,87 +467,64 @@ public class RouterActivity extends AppCompatActivity { private List getChoicesForService(final StreamingService service, final LinkType linkType) { - final Context context = getThemeWrapperContext(); - - final List returnList = new ArrayList<>(); - final List capabilities - = service.getServiceInfo().getMediaCapabilities(); - - final SharedPreferences preferences = PreferenceManager - .getDefaultSharedPreferences(this); - final boolean isExtVideoEnabled = preferences.getBoolean( - getString(R.string.use_external_video_player_key), false); - final boolean isExtAudioEnabled = preferences.getBoolean( - getString(R.string.use_external_audio_player_key), false); - - final AdapterChoiceItem videoPlayer = new AdapterChoiceItem( - getString(R.string.video_player_key), getString(R.string.video_player), - R.drawable.ic_play_arrow); final AdapterChoiceItem showInfo = new AdapterChoiceItem( getString(R.string.show_info_key), getString(R.string.show_info), R.drawable.ic_info_outline); - final AdapterChoiceItem popupPlayer = new AdapterChoiceItem( - getString(R.string.popup_player_key), getString(R.string.popup_player), - R.drawable.ic_picture_in_picture); + final AdapterChoiceItem videoPlayer = new AdapterChoiceItem( + getString(R.string.video_player_key), getString(R.string.video_player), + R.drawable.ic_play_arrow); final AdapterChoiceItem backgroundPlayer = new AdapterChoiceItem( getString(R.string.background_player_key), getString(R.string.background_player), R.drawable.ic_headset); - final AdapterChoiceItem addToPlaylist = new AdapterChoiceItem( - getString(R.string.add_to_playlist_key), getString(R.string.add_to_playlist), - R.drawable.ic_add); + final AdapterChoiceItem popupPlayer = new AdapterChoiceItem( + getString(R.string.popup_player_key), getString(R.string.popup_player), + R.drawable.ic_picture_in_picture); + final List returnedItems = new ArrayList<>(); + returnedItems.add(showInfo); // Always present + + final List capabilities = + service.getServiceInfo().getMediaCapabilities(); if (linkType == LinkType.STREAM) { - if (isExtVideoEnabled) { - // show both "show info" and "video player", they are two different activities - returnList.add(showInfo); - returnList.add(videoPlayer); - } else { - final MainPlayer.PlayerType playerType = PlayerHolder.getInstance().getType(); - if (capabilities.contains(VIDEO) - && PlayerHelper.isAutoplayAllowedByUser(context) - && playerType == null || playerType == MainPlayer.PlayerType.VIDEO) { - // show only "video player" since the details activity will be opened and the - // video will be auto played there. Since "show info" would do the exact same - // thing, use that as a key to let VideoDetailFragment load the stream instead - // of using FetcherService (see comment in handleChoice()) - returnList.add(new AdapterChoiceItem( - showInfo.key, videoPlayer.description, videoPlayer.icon)); - } else { - // show only "show info" if video player is not applicable, auto play is - // disabled or a video is playing in a player different than the main one - returnList.add(showInfo); - } - } - if (capabilities.contains(VIDEO)) { - returnList.add(popupPlayer); + returnedItems.add(videoPlayer); + returnedItems.add(popupPlayer); } if (capabilities.contains(AUDIO)) { - returnList.add(backgroundPlayer); + returnedItems.add(backgroundPlayer); } // download is redundant for linkType CHANNEL AND PLAYLIST (till playlist downloading is // not supported ) - returnList.add(new AdapterChoiceItem(getString(R.string.download_key), + returnedItems.add(new AdapterChoiceItem(getString(R.string.download_key), getString(R.string.download), R.drawable.ic_file_download)); // Add to playlist is not necessary for CHANNEL and PLAYLIST linkType since those can // not be added to a playlist - returnList.add(addToPlaylist); - + returnedItems.add(new AdapterChoiceItem(getString(R.string.add_to_playlist_key), + getString(R.string.add_to_playlist), + R.drawable.ic_add)); } else { - returnList.add(showInfo); + // LinkType.NONE is never present because it's filtered out before + // channels and playlist can be played as they contain a list of videos + final SharedPreferences preferences = PreferenceManager + .getDefaultSharedPreferences(this); + final boolean isExtVideoEnabled = preferences.getBoolean( + getString(R.string.use_external_video_player_key), false); + final boolean isExtAudioEnabled = preferences.getBoolean( + getString(R.string.use_external_audio_player_key), false); + if (capabilities.contains(VIDEO) && !isExtVideoEnabled) { - returnList.add(videoPlayer); - returnList.add(popupPlayer); + returnedItems.add(videoPlayer); + returnedItems.add(popupPlayer); } if (capabilities.contains(AUDIO) && !isExtAudioEnabled) { - returnList.add(backgroundPlayer); + returnedItems.add(backgroundPlayer); } } - return returnList; + return returnedItems; } private Context getThemeWrapperContext() { @@ -567,7 +586,8 @@ public class RouterActivity extends AppCompatActivity { // stop and bypass FetcherService if InfoScreen was selected since // StreamDetailFragment can fetch data itself - if (selectedChoiceKey.equals(getString(R.string.show_info_key))) { + if (selectedChoiceKey.equals(getString(R.string.show_info_key)) + || canHandleChoiceLikeShowInfo(selectedChoiceKey)) { disposables.add(Observable .fromCallable(() -> NavigationHelper.getIntentByLink(this, currentUrl)) .subscribeOn(Schedulers.io()) @@ -590,6 +610,30 @@ public class RouterActivity extends AppCompatActivity { finish(); } + private boolean canHandleChoiceLikeShowInfo(final String selectedChoiceKey) { + if (!selectedChoiceKey.equals(getString(R.string.video_player_key))) { + return false; + } + // "video player" can be handled like "show info" (because VideoDetailFragment can load + // the stream instead of FetcherService) when... + + // ...Autoplay is enabled + if (!PlayerHelper.isAutoplayAllowedByUser(getThemeWrapperContext())) { + return false; + } + + final boolean isExtVideoEnabled = PreferenceManager.getDefaultSharedPreferences(this) + .getBoolean(getString(R.string.use_external_video_player_key), false); + // ...it's not done via an external player + if (isExtVideoEnabled) { + return false; + } + + // ...the player is not running or in normal Video-mode/type + final MainPlayer.PlayerType playerType = PlayerHolder.getInstance().getType(); + return playerType == null || playerType == MainPlayer.PlayerType.VIDEO; + } + private void openAddToPlaylistDialog() { // Getting the stream info usually takes a moment // Notifying the user here to ensure that no confusion arises @@ -631,22 +675,13 @@ public class RouterActivity extends AppCompatActivity { .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(result -> { - final List sortedVideoStreams = ListHelper - .getSortedStreamVideosList(this, result.getVideoStreams(), - result.getVideoOnlyStreams(), false); - final int selectedVideoStreamIndex = ListHelper - .getDefaultResolutionIndex(this, sortedVideoStreams); + final DownloadDialog downloadDialog = new DownloadDialog(this, result); + downloadDialog.setOnDismissListener(dialog -> finish()); final FragmentManager fm = getSupportFragmentManager(); - final DownloadDialog downloadDialog = DownloadDialog.newInstance(result); - downloadDialog.setVideoStreams(sortedVideoStreams); - downloadDialog.setAudioStreams(result.getAudioStreams()); - downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex); - downloadDialog.setOnDismissListener(dialog -> finish()); downloadDialog.show(fm, "downloadDialog"); fm.executePendingTransactions(); - }, throwable -> - showUnsupportedUrlDialog(currentUrl))); + }, throwable -> showUnsupportedUrlDialog(currentUrl))); } @Override @@ -672,8 +707,8 @@ public class RouterActivity extends AppCompatActivity { final int icon; AdapterChoiceItem(final String key, final String description, final int icon) { - this.description = description; this.key = key; + this.description = description; this.icon = icon; } } 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..d8c19c1e9 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 @@ -12,8 +12,7 @@ import org.schabi.newpipe.database.BasicDAO import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID import org.schabi.newpipe.extractor.stream.StreamType -import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM -import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM +import org.schabi.newpipe.util.StreamTypeUtil import java.time.OffsetDateTime @Dao @@ -39,6 +38,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 @@ -88,8 +90,7 @@ abstract class StreamDAO : BasicDAO { ?: throw IllegalStateException("Stream cannot be null just after insertion.") newerStream.uid = existentMinimalStream.uid - val isNewerStreamLive = newerStream.streamType == AUDIO_LIVE_STREAM || newerStream.streamType == LIVE_STREAM - if (!isNewerStreamLive) { + if (!StreamTypeUtil.isLiveStream(newerStream.streamType)) { // Use the existent upload date if the newer stream does not have a better precision // (i.e. is an approximation). This is done to prevent unnecessary changes. 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 fd63d7675..1f67d3788 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; @@ -68,9 +69,9 @@ import org.schabi.newpipe.util.VideoSegment; import java.io.File; import java.io.IOException; -import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Objects; import icepick.Icepick; import icepick.State; @@ -82,6 +83,8 @@ import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder; import us.shandian.giga.service.MissionState; +import static org.schabi.newpipe.extractor.stream.DeliveryMethod.PROGRESSIVE_HTTP; +import static org.schabi.newpipe.util.ListHelper.getStreamsOfSpecifiedDelivery; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; public class DownloadDialog extends DialogFragment @@ -92,17 +95,17 @@ public class DownloadDialog extends DialogFragment @State StreamInfo currentInfo; @State - StreamSizeWrapper wrappedAudioStreams = StreamSizeWrapper.empty(); + StreamSizeWrapper wrappedAudioStreams; @State - StreamSizeWrapper wrappedVideoStreams = StreamSizeWrapper.empty(); + StreamSizeWrapper wrappedVideoStreams; @State - StreamSizeWrapper wrappedSubtitleStreams = StreamSizeWrapper.empty(); + StreamSizeWrapper wrappedSubtitleStreams; @State - int selectedVideoIndex = 0; + int selectedVideoIndex; // set in the constructor @State - int selectedAudioIndex = 0; + int selectedAudioIndex = 0; // default to the first item @State - int selectedSubtitleIndex = 0; + int selectedSubtitleIndex = 0; // default to the first item @Nullable private OnDismissListener onDismissListener = null; @@ -145,81 +148,47 @@ public class DownloadDialog extends DialogFragment // Instance creation //////////////////////////////////////////////////////////////////////////*/ - public static DownloadDialog newInstance(final StreamInfo info) { - final DownloadDialog dialog = new DownloadDialog(); - dialog.setInfo(info); - return dialog; - } - - public static DownloadDialog newInstance(final Context context, final StreamInfo info) { - final ArrayList streamsList = new ArrayList<>(ListHelper - .getSortedStreamVideosList(context, info.getVideoStreams(), - info.getVideoOnlyStreams(), false)); - final int selectedStreamIndex = ListHelper.getDefaultResolutionIndex(context, streamsList); - - final DownloadDialog instance = newInstance(info); - instance.setVideoStreams(streamsList); - instance.setSelectedVideoStream(selectedStreamIndex); - instance.setAudioStreams(info.getAudioStreams()); - instance.setSubtitleStreams(info.getSubtitles()); - - return instance; - } - - - /*////////////////////////////////////////////////////////////////////////// - // Setters - //////////////////////////////////////////////////////////////////////////*/ - - private void setInfo(final StreamInfo info) { + /** + * Create a new download dialog with the video, audio and subtitle streams from the provided + * stream info. Video streams and video-only streams will be put into a single list menu, + * sorted according to their resolution and the default video resolution will be selected. + * + * @param context the context to use just to obtain preferences and strings (will not be stored) + * @param info the info from which to obtain downloadable streams and other info (e.g. title) + */ + public DownloadDialog(final Context context, @NonNull final StreamInfo info) { this.currentInfo = info; - } - public void setAudioStreams(final List audioStreams) { - setAudioStreams(new StreamSizeWrapper<>(audioStreams, getContext())); - } + // TODO: Adapt this code when the downloader support other types of stream deliveries + final List videoStreams = ListHelper.getSortedStreamVideosList( + context, + getStreamsOfSpecifiedDelivery(info.getVideoStreams(), PROGRESSIVE_HTTP), + getStreamsOfSpecifiedDelivery(info.getVideoOnlyStreams(), PROGRESSIVE_HTTP), + false, + false + ); - public void setAudioStreams(final StreamSizeWrapper was) { - this.wrappedAudioStreams = was; - } + this.wrappedVideoStreams = new StreamSizeWrapper<>(videoStreams, context); + this.wrappedAudioStreams = new StreamSizeWrapper<>( + getStreamsOfSpecifiedDelivery(info.getAudioStreams(), PROGRESSIVE_HTTP), context); + this.wrappedSubtitleStreams = new StreamSizeWrapper<>( + getStreamsOfSpecifiedDelivery(info.getSubtitles(), PROGRESSIVE_HTTP), context); - public void setVideoStreams(final List videoStreams) { - setVideoStreams(new StreamSizeWrapper<>(videoStreams, getContext())); - } - - public void setVideoStreams(final StreamSizeWrapper wvs) { - this.wrappedVideoStreams = wvs; - } - - public void setSubtitleStreams(final List subtitleStreams) { - setSubtitleStreams(new StreamSizeWrapper<>(subtitleStreams, getContext())); - } - - public void setSubtitleStreams( - final StreamSizeWrapper wss) { - this.wrappedSubtitleStreams = wss; - } - - public void setSelectedVideoStream(final int svi) { - this.selectedVideoIndex = svi; - } - - public void setSelectedAudioStream(final int sai) { - this.selectedAudioIndex = sai; - } - - public void setSelectedSubtitleStream(final int ssi) { - this.selectedSubtitleIndex = ssi; + this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams); } public void setVideoSegments(final VideoSegment[] seg) { this.segments = seg; } + /** + * @param onDismissListener the listener to call in {@link #onDismiss(DialogInterface)} + */ public void setOnDismissListener(@Nullable final OnDismissListener onDismissListener) { this.onDismissListener = onDismissListener; } + /*////////////////////////////////////////////////////////////////////////// // Android lifecycle //////////////////////////////////////////////////////////////////////////*/ @@ -255,11 +224,16 @@ public class DownloadDialog extends DialogFragment .getAudioStreamFor(wrappedAudioStreams.getStreamsList(), videoStreams.get(i)); if (audioStream != null) { - secondaryStreams - .append(i, new SecondaryStreamHelper<>(wrappedAudioStreams, audioStream)); + secondaryStreams.append(i, new SecondaryStreamHelper<>(wrappedAudioStreams, + audioStream)); } else if (DEBUG) { - Log.w(TAG, "No audio stream candidates for video format " - + videoStreams.get(i).getFormat().name()); + final MediaFormat mediaFormat = videoStreams.get(i).getFormat(); + if (mediaFormat != null) { + Log.w(TAG, "No audio stream candidates for video format " + + mediaFormat.name()); + } else { + Log.w(TAG, "No audio stream candidates for unknown video format"); + } } } @@ -294,7 +268,8 @@ public class DownloadDialog extends DialogFragment } @Override - public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, + public View onCreateView(@NonNull final LayoutInflater inflater, + final ViewGroup container, final Bundle savedInstanceState) { if (DEBUG) { Log.d(TAG, "onCreateView() called with: " @@ -305,14 +280,15 @@ public class DownloadDialog extends DialogFragment } @Override - public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + public void onViewCreated(@NonNull final View view, + @Nullable final Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); dialogBinding = DownloadDialogBinding.bind(view); dialogBinding.fileName.setText(FilenameUtils.createFilename(getContext(), currentInfo.getName())); selectedAudioIndex = ListHelper - .getDefaultAudioFormat(getContext(), currentInfo.getAudioStreams()); + .getDefaultAudioFormat(getContext(), wrappedAudioStreams.getStreamsList()); selectedSubtitleIndex = getSubtitleIndexBy(subtitleStreamsAdapter.getAll()); @@ -328,21 +304,16 @@ 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(); @@ -481,7 +452,7 @@ public class DownloadDialog extends DialogFragment result, getString(R.string.download_path_video_key), DownloadManager.TAG_VIDEO); } - private void requestDownloadSaveAsResult(final ActivityResult result) { + private void requestDownloadSaveAsResult(@NonNull final ActivityResult result) { if (result.getResultCode() != Activity.RESULT_OK) { return; } @@ -498,8 +469,8 @@ public class DownloadDialog extends DialogFragment return; } - final DocumentFile docFile - = DocumentFile.fromSingleUri(context, result.getData().getData()); + final DocumentFile docFile = DocumentFile.fromSingleUri(context, + result.getData().getData()); if (docFile == null) { showFailedDialog(R.string.general_error); return; @@ -510,7 +481,7 @@ public class DownloadDialog extends DialogFragment docFile.getType()); } - private void requestDownloadPickFolderResult(final ActivityResult result, + private void requestDownloadPickFolderResult(@NonNull final ActivityResult result, final String key, final String tag) { if (result.getResultCode() != Activity.RESULT_OK) { @@ -530,12 +501,11 @@ public class DownloadDialog extends DialogFragment StoredDirectoryHelper.PERMISSION_FLAGS); } - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putString(key, uri.toString()).apply(); + PreferenceManager.getDefaultSharedPreferences(context).edit().putString(key, + uri.toString()).apply(); try { - final StoredDirectoryHelper mainStorage - = new StoredDirectoryHelper(context, uri, tag); + final StoredDirectoryHelper mainStorage = new StoredDirectoryHelper(context, uri, tag); checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp, mimeTmp); } catch (final IOException e) { @@ -573,8 +543,10 @@ public class DownloadDialog extends DialogFragment } @Override - public void onItemSelected(final AdapterView parent, final View view, - final int position, final long id) { + public void onItemSelected(final AdapterView parent, + final View view, + final int position, + final long id) { if (DEBUG) { Log.d(TAG, "onItemSelected() called with: " + "parent = [" + parent + "], view = [" + view + "], " @@ -609,14 +581,16 @@ public class DownloadDialog extends DialogFragment final boolean isAudioStreamsAvailable = audioStreamsAdapter.getCount() > 0; final boolean isSubtitleStreamsAvailable = subtitleStreamsAdapter.getCount() > 0; - dialogBinding.audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE : View.GONE); - dialogBinding.videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE : View.GONE); + dialogBinding.audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE + : View.GONE); + dialogBinding.videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE + : View.GONE); dialogBinding.subtitleButton.setVisibility(isSubtitleStreamsAvailable ? View.VISIBLE : View.GONE); prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); final String defaultMedia = prefs.getString(getString(R.string.last_used_download_type), - getString(R.string.last_download_type_video_key)); + getString(R.string.last_download_type_video_key)); if (isVideoStreamsAvailable && (defaultMedia.equals(getString(R.string.last_download_type_video_key)))) { @@ -652,7 +626,7 @@ public class DownloadDialog extends DialogFragment dialogBinding.subtitleButton.setEnabled(enabled); } - private int getSubtitleIndexBy(final List streams) { + private int getSubtitleIndexBy(@NonNull final List streams) { final Localization preferredLocalization = NewPipe.getPreferredLocalization(); int candidate = 0; @@ -678,8 +652,10 @@ public class DownloadDialog extends DialogFragment return candidate; } + @NonNull private String getNameEditText() { - final String str = dialogBinding.fileName.getText().toString().trim(); + final String str = Objects.requireNonNull(dialogBinding.fileName.getText()).toString() + .trim(); return FilenameUtils.createFilename(context, str.isEmpty() ? currentInfo.getName() : str); } @@ -695,12 +671,8 @@ public class DownloadDialog extends DialogFragment } private void launchDirectoryPicker(final ActivityResultLauncher launcher) { - NoFileManagerSafeGuard.launchSafe( - launcher, - StoredDirectoryHelper.getPicker(context), - TAG, - context - ); + NoFileManagerSafeGuard.launchSafe(launcher, StoredDirectoryHelper.getPicker(context), TAG, + context); } private void prepareSelectedDownload() { @@ -721,7 +693,7 @@ public class DownloadDialog extends DialogFragment if (format == MediaFormat.WEBMA_OPUS) { mimeTmp = "audio/ogg"; filenameTmp += "opus"; - } else { + } else if (format != null) { mimeTmp = format.mimeType; filenameTmp += format.suffix; } @@ -730,22 +702,30 @@ public class DownloadDialog extends DialogFragment selectedMediaType = getString(R.string.last_download_type_video_key); mainStorage = mainStorageVideo; format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat(); - mimeTmp = format.mimeType; - filenameTmp += format.suffix; + if (format != null) { + mimeTmp = format.mimeType; + filenameTmp += format.suffix; + } break; case R.id.subtitle_button: selectedMediaType = getString(R.string.last_download_type_subtitle_key); mainStorage = mainStorageVideo; // subtitle & video files go together format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat(); - mimeTmp = format.mimeType; - filenameTmp += (format == MediaFormat.TTML ? MediaFormat.SRT : format).suffix; + if (format != null) { + mimeTmp = format.mimeType; + } + + if (format == MediaFormat.TTML) { + filenameTmp += MediaFormat.SRT.suffix; + } else if (format != null) { + filenameTmp += format.suffix; + } break; default: throw new RuntimeException("No stream selected"); } - if (!askForSavePath - && (mainStorage == null + if (!askForSavePath && (mainStorage == null || mainStorage.isDirect() == NewPipeSettings.useStorageAccessFramework(context) || mainStorage.isInvalidSafStorage())) { // Pick new download folder if one of: @@ -779,18 +759,16 @@ public class DownloadDialog extends DialogFragment initialPath = Uri.parse(initialSavePath.getAbsolutePath()); } - NoFileManagerSafeGuard.launchSafe( - requestDownloadSaveAsLauncher, - StoredFileHelper.getNewPicker(context, filenameTmp, mimeTmp, initialPath), - TAG, - context - ); + NoFileManagerSafeGuard.launchSafe(requestDownloadSaveAsLauncher, + StoredFileHelper.getNewPicker(context, filenameTmp, mimeTmp, initialPath), TAG, + context); return; } // check for existing file with the same name - checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp, mimeTmp); + checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp, + mimeTmp); // remember the last media type downloaded by the user prefs.edit().putString(getString(R.string.last_used_download_type), selectedMediaType) @@ -798,7 +776,8 @@ public class DownloadDialog extends DialogFragment } private void checkSelectedDownload(final StoredDirectoryHelper mainStorage, - final Uri targetFile, final String filename, + final Uri targetFile, + final String filename, final String mime) { StoredFileHelper storage; @@ -959,7 +938,7 @@ public class DownloadDialog extends DialogFragment storage.truncate(); } } catch (final IOException e) { - Log.e(TAG, "failed to truncate the file: " + storage.getUri().toString(), e); + Log.e(TAG, "Failed to truncate the file: " + storage.getUri().toString(), e); showFailedDialog(R.string.overwrite_failed); return; } @@ -1004,8 +983,8 @@ public class DownloadDialog extends DialogFragment } psArgs = null; - final long videoSize = wrappedVideoStreams - .getSizeInBytes((VideoStream) selectedStream); + final long videoSize = wrappedVideoStreams.getSizeInBytes( + (VideoStream) selectedStream); // set nearLength, only, if both sizes are fetched or known. This probably // does not work on slow networks but is later updated in the downloader @@ -1021,7 +1000,7 @@ public class DownloadDialog extends DialogFragment if (selectedStream.getFormat() == MediaFormat.TTML) { psName = Postprocessing.ALGORITHM_TTML_CONVERTER; - psArgs = new String[]{ + psArgs = new String[] { selectedStream.getFormat().getSuffix(), "false" // ignore empty frames }; @@ -1032,17 +1011,22 @@ public class DownloadDialog extends DialogFragment } if (secondaryStream == null) { - urls = new String[]{ - selectedStream.getUrl() + urls = new String[] { + selectedStream.getContent() }; - recoveryInfo = new MissionRecoveryInfo[]{ + recoveryInfo = new MissionRecoveryInfo[] { new MissionRecoveryInfo(selectedStream) }; } else { - urls = new String[]{ - selectedStream.getUrl(), secondaryStream.getUrl() + if (secondaryStream.getDeliveryMethod() != PROGRESSIVE_HTTP) { + throw new IllegalArgumentException("Unsupported stream delivery format" + + secondaryStream.getDeliveryMethod()); + } + + urls = new String[] { + selectedStream.getContent(), secondaryStream.getContent() }; - recoveryInfo = new MissionRecoveryInfo[]{new MissionRecoveryInfo(selectedStream), + recoveryInfo = new MissionRecoveryInfo[] {new MissionRecoveryInfo(selectedStream), new MissionRecoveryInfo(secondaryStream)}; } diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt index b2ba912ec..f9f9f003a 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt @@ -7,13 +7,13 @@ import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.schabi.newpipe.R import org.schabi.newpipe.extractor.Info -import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException import org.schabi.newpipe.extractor.exceptions.ExtractionException import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor.DeobfuscateException import org.schabi.newpipe.ktx.isNetworkRelated +import org.schabi.newpipe.util.ServiceHelper import java.io.PrintWriter import java.io.StringWriter @@ -65,7 +65,7 @@ class ErrorInfo( constructor(throwable: Throwable, userAction: UserAction, request: String) : this(throwable, userAction, SERVICE_NONE, request) constructor(throwable: Throwable, userAction: UserAction, request: String, serviceId: Int) : - this(throwable, userAction, NewPipe.getNameOfService(serviceId), request) + this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request) constructor(throwable: Throwable, userAction: UserAction, request: String, info: Info?) : this(throwable, userAction, getInfoServiceName(info), request) @@ -73,7 +73,7 @@ class ErrorInfo( constructor(throwable: List, userAction: UserAction, request: String) : this(throwable, userAction, SERVICE_NONE, request) constructor(throwable: List, userAction: UserAction, request: String, serviceId: Int) : - this(throwable, userAction, NewPipe.getNameOfService(serviceId), request) + this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request) constructor(throwable: List, userAction: UserAction, request: String, info: Info?) : this(throwable, userAction, getInfoServiceName(info), request) @@ -95,7 +95,7 @@ class ErrorInfo( Array(throwable.size) { i -> getStackTrace(throwable[i]) } private fun getInfoServiceName(info: Info?) = - if (info == null) SERVICE_NONE else NewPipe.getNameOfService(info.serviceId) + if (info == null) SERVICE_NONE else ServiceHelper.getNameOfServiceById(info.serviceId) @StringRes private fun getMessageStringId( diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt index 692cb427a..15343f53d 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt @@ -15,7 +15,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.disposables.Disposable import org.schabi.newpipe.MainActivity import org.schabi.newpipe.R -import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException @@ -106,7 +105,7 @@ class ErrorPanelHelper( if (!isNullOrEmpty((errorInfo.throwable as AccountTerminatedException).message)) { errorServiceInfoTextView.text = context.resources.getString( R.string.service_provides_reason, - NewPipe.getNameOfService(ServiceHelper.getSelectedServiceId(context)) + ServiceHelper.getSelectedService(context)?.serviceInfo?.name ?: "" ) errorServiceInfoTextView.isVisible = true 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 d43a0d56c..a545fdb97 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 androidx.preference.PreferenceManager import com.google.android.material.snackbar.Snackbar @@ -114,13 +113,6 @@ class ErrorUtil { return } - 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 @@ -131,7 +123,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) @@ -144,7 +142,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/DescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java index 9b1bf121b..d57ddb02d 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java @@ -84,7 +84,7 @@ public class DescriptionFragment extends BaseFragment { private void setupDescription() { final Description description = streamInfo.getDescription(); if (description == null || isEmpty(description.getContent()) - || description == Description.emptyDescription) { + || description == Description.EMPTY_DESCRIPTION) { binding.detailDescriptionView.setVisibility(View.GONE); binding.detailSelectDescriptionButton.setVisibility(View.GONE); return; 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 0de8a4d61..de3f5f30c 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -31,6 +31,7 @@ import android.view.WindowManager; import android.view.animation.DecelerateInterpolator; import android.widget.FrameLayout; import android.widget.RelativeLayout; +import android.widget.Toast; import androidx.annotation.AttrRes; import androidx.annotation.NonNull; @@ -43,7 +44,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; @@ -97,6 +98,7 @@ import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.util.SponsorBlockUtils; import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.ThemeHelper; @@ -125,6 +127,7 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientat import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfirmationRequired; import static org.schabi.newpipe.player.playqueue.PlayQueueItem.RECOVERY_UNSET; import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; +import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; public final class VideoDetailFragment extends BaseStateFragment @@ -192,8 +195,6 @@ public final class VideoDetailFragment @Nullable private Disposable videoSegmentsSubscriber = null; - private List sortedVideoStreams; - private int selectedVideoStreamIndex = -1; private BottomSheetBehavior bottomSheetBehavior; private BroadcastReceiver broadcastReceiver; @@ -672,8 +673,7 @@ public final class VideoDetailFragment binding.detailControlsCrashThePlayer.setOnClickListener( v -> VideoDetailPlayerCrasher.onCrashThePlayer( this.getContext(), - this.player, - getLayoutInflater()) + this.player) ); } @@ -1102,9 +1102,6 @@ public final class VideoDetailFragment } private void openBackgroundPlayer(final boolean append) { - final AudioStream audioStream = currentInfo.getAudioStreams() - .get(ListHelper.getDefaultAudioFormat(activity, currentInfo.getAudioStreams())); - final boolean useExternalAudioPlayer = PreferenceManager .getDefaultSharedPreferences(activity) .getBoolean(activity.getString(R.string.use_external_audio_player_key), false); @@ -1119,7 +1116,17 @@ public final class VideoDetailFragment if (!useExternalAudioPlayer) { openNormalBackgroundPlayer(append); } else { - startOnExternalPlayer(activity, currentInfo, audioStream); + final List audioStreams = getUrlAndNonTorrentStreams( + currentInfo.getAudioStreams()); + final int index = ListHelper.getDefaultAudioFormat(activity, audioStreams); + + if (index == -1) { + Toast.makeText(activity, R.string.no_audio_streams_available_for_external_players, + Toast.LENGTH_SHORT).show(); + return; + } + + startOnExternalPlayer(activity, currentInfo, audioStreams.get(index)); } } @@ -1635,13 +1642,6 @@ public final class VideoDetailFragment binding.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE); binding.detailSecondaryControlPanel.setVisibility(View.GONE); - sortedVideoStreams = ListHelper.getSortedStreamVideosList( - activity, - info.getVideoStreams(), - info.getVideoOnlyStreams(), - false); - selectedVideoStreamIndex = ListHelper - .getDefaultResolutionIndex(activity, sortedVideoStreams); updateProgressInfo(info); initThumbnailViews(info); showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, @@ -1667,8 +1667,8 @@ public final class VideoDetailFragment } } - binding.detailControlsDownload.setVisibility(info.getStreamType() == StreamType.LIVE_STREAM - || info.getStreamType() == StreamType.AUDIO_LIVE_STREAM ? View.GONE : View.VISIBLE); + binding.detailControlsDownload.setVisibility( + StreamTypeUtil.isLiveStream(info.getStreamType()) ? View.GONE : View.VISIBLE); binding.detailControlsBackground.setVisibility(info.getAudioStreams().isEmpty() ? View.GONE : View.VISIBLE); @@ -1726,18 +1726,12 @@ public final class VideoDetailFragment .observeOn(AndroidSchedulers.mainThread()) .subscribe(videoSegments -> { try { - final DownloadDialog downloadDialog = DownloadDialog.newInstance(currentInfo); - downloadDialog.setVideoStreams(sortedVideoStreams); - downloadDialog.setAudioStreams(currentInfo.getAudioStreams()); - downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex); - downloadDialog.setSubtitleStreams(currentInfo.getSubtitles()); + final DownloadDialog downloadDialog = new DownloadDialog(activity, currentInfo); downloadDialog.setVideoSegments(videoSegments); downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog"); } catch (final Exception e) { - ErrorUtil.showSnackbar(activity, - new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG, - "Showing download dialog", - currentInfo)); + ErrorUtil.showSnackbar(activity, new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG, + "Showing download dialog", currentInfo)); } }); } @@ -1764,8 +1758,7 @@ public final class VideoDetailFragment binding.detailPositionView.setVisibility(View.GONE); // TODO: Remove this check when separation of concerns is done. // (live streams weren't getting updated because they are mixed) - if (!info.getStreamType().equals(StreamType.LIVE_STREAM) - && !info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { + if (!StreamTypeUtil.isLiveStream(info.getStreamType())) { return; } } else { @@ -1925,9 +1918,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(); @@ -2194,25 +2186,52 @@ public final class VideoDetailFragment } private void showExternalPlaybackDialog() { - if (sortedVideoStreams == null) { + if (currentInfo == null) { return; } - final CharSequence[] resolutions = new CharSequence[sortedVideoStreams.size()]; - for (int i = 0; i < sortedVideoStreams.size(); i++) { - resolutions[i] = sortedVideoStreams.get(i).getResolution(); - } - final AlertDialog.Builder builder = new AlertDialog.Builder(activity) - .setNegativeButton(R.string.cancel, null) - .setNeutralButton(R.string.open_in_browser, (dialog, i) -> - ShareUtils.openUrlInBrowser(requireActivity(), url) + + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle(R.string.select_quality_external_players); + builder.setNeutralButton(R.string.open_in_browser, (dialog, i) -> + ShareUtils.openUrlInBrowser(requireActivity(), url)); + + final List videoStreamsForExternalPlayers = + ListHelper.getSortedStreamVideosList( + activity, + getUrlAndNonTorrentStreams(currentInfo.getVideoStreams()), + getUrlAndNonTorrentStreams(currentInfo.getVideoOnlyStreams()), + false, + false ); - // Maybe there are no video streams available, show just `open in browser` button - if (resolutions.length > 0) { - builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndex, (dialog, i) -> { - dialog.dismiss(); - startOnExternalPlayer(activity, currentInfo, sortedVideoStreams.get(i)); - } - ); + + if (videoStreamsForExternalPlayers.isEmpty()) { + builder.setMessage(R.string.no_video_streams_available_for_external_players); + builder.setPositiveButton(R.string.ok, null); + + } else { + final int selectedVideoStreamIndexForExternalPlayers = + ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers); + final CharSequence[] resolutions = + new CharSequence[videoStreamsForExternalPlayers.size()]; + + for (int i = 0; i < videoStreamsForExternalPlayers.size(); i++) { + resolutions[i] = videoStreamsForExternalPlayers.get(i).getResolution(); + } + + builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndexForExternalPlayers, + null); + builder.setNegativeButton(R.string.cancel, null); + builder.setPositiveButton(R.string.ok, (dialog, i) -> { + final int index = ((AlertDialog) dialog).getListView().getCheckedItemPosition(); + // We don't have to manage the index validity because if there is no stream + // available for external players, this code will be not executed and if there is + // no stream which matches the default resolution, 0 is returned by + // ListHelper.getDefaultResolutionIndex. + // The index cannot be outside the bounds of the list as its always between 0 and + // the list size - 1, . + startOnExternalPlayer(activity, currentInfo, + videoStreamsForExternalPlayers.get(index)); + }); } builder.show(); } 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..55336a42f 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 @@ -1,5 +1,9 @@ package org.schabi.newpipe.fragments.detail; +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; + import android.content.Context; import android.util.Log; import android.view.ContextThemeWrapper; @@ -15,6 +19,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; @@ -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( @@ -88,8 +97,7 @@ public final class VideoDetailPlayerCrasher { public static void onCrashThePlayer( @NonNull final Context context, - @Nullable final Player player, - @NonNull final LayoutInflater layoutInflater + @Nullable final Player player ) { if (player == null) { Log.d(TAG, "Player is not available"); @@ -100,16 +108,15 @@ public final class VideoDetailPlayerCrasher { } // -- Build the dialog/UI -- - final Context themeWrapperContext = getThemeWrapperContext(context); - final LayoutInflater inflater = LayoutInflater.from(themeWrapperContext); - final RadioGroup radioGroup = SingleChoiceDialogViewBinding.inflate(layoutInflater) - .list; - final AlertDialog alertDialog = new AlertDialog.Builder(getThemeWrapperContext(context)) + final SingleChoiceDialogViewBinding binding = + SingleChoiceDialogViewBinding.inflate(inflater); + + final AlertDialog alertDialog = new AlertDialog.Builder(themeWrapperContext) .setTitle("Choose an exception") - .setView(radioGroup) + .setView(binding.getRoot()) .setCancelable(true) .setNegativeButton(R.string.cancel, null) .create(); @@ -127,11 +134,9 @@ public final class VideoDetailPlayerCrasher { ); radioButton.setOnClickListener(v -> { tryCrashPlayerWith(player, entry.getValue().get()); - if (alertDialog != null) { - alertDialog.cancel(); - } + alertDialog.cancel(); }); - radioGroup.addView(radioButton); + binding.list.addView(radioButton); } alertDialog.show(); @@ -139,7 +144,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..fa8f5fdbd 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; @@ -73,6 +77,8 @@ public class ChannelFragment extends BaseListInfoFragment private final CompositeDisposable disposables = new CompositeDisposable(); private Disposable subscribeButtonMonitor; + private boolean channelContentNotSupported = false; + /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ @@ -84,6 +90,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) { @@ -125,6 +132,7 @@ public class ChannelFragment extends BaseListInfoFragment public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { super.onViewCreated(rootView, savedInstanceState); channelBinding = FragmentChannelBinding.bind(rootView); + showContentNotSupportedIfNeeded(); } @Override @@ -179,6 +187,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 +197,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 +246,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 +341,7 @@ public class ChannelFragment extends BaseListInfoFragment info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount()); + updateNotifyButton(null); subscribeButtonMonitor = monitorSubscribeButton( headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info)); } else { @@ -327,6 +349,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 +392,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); } @@ -465,9 +527,12 @@ public class ChannelFragment extends BaseListInfoFragment playlistControlBinding.getRoot().setVisibility(View.GONE); } + channelContentNotSupported = false; for (final Throwable throwable : result.getErrors()) { if (throwable instanceof ContentNotSupportedException) { - showContentNotSupported(); + channelContentNotSupported = true; + showContentNotSupportedIfNeeded(); + break; } } @@ -499,7 +564,13 @@ public class ChannelFragment extends BaseListInfoFragment }); } - private void showContentNotSupported() { + private void showContentNotSupportedIfNeeded() { + // channelBinding might not be initialized when handleResult() is called + // (e.g. after rotating the screen, #6696) + if (!channelContentNotSupported || channelBinding == null) { + return; + } + channelBinding.errorContentNotSupported.setVisibility(View.VISIBLE); channelBinding.channelKaomoji.setText("(︶︹︺)"); channelBinding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f); 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..ed63c6fd7 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,10 +1,8 @@ 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.os.Bundle; import android.text.TextUtils; @@ -20,11 +18,15 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; +import com.google.android.material.shape.CornerFamily; +import com.google.android.material.shape.ShapeAppearanceModel; + import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; +import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.databinding.PlaylistControlBinding; import org.schabi.newpipe.databinding.PlaylistHeaderBinding; import org.schabi.newpipe.error.ErrorInfo; @@ -36,27 +38,25 @@ 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.info_list.dialog.StreamDialogDefaultEntry; +import org.schabi.newpipe.local.dialog.PlaylistDialog; 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.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; +import java.util.stream.Collectors; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Flowable; @@ -64,7 +64,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 +140,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 +211,7 @@ public class PlaylistFragment extends BaseListInfoFragment { //////////////////////////////////////////////////////////////////////////*/ @Override - protected Single loadMoreItemsLogic() { + protected Single> loadMoreItemsLogic() { return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPage); } @@ -276,6 +238,17 @@ public class PlaylistFragment extends BaseListInfoFragment { case R.id.menu_item_bookmark: onBookmarkClicked(); break; + case R.id.menu_item_append_playlist: + disposables.add(PlaylistDialog.createCorrespondingDialog( + getContext(), + getPlayQueue() + .getStreams() + .stream() + .map(StreamEntity::new) + .collect(Collectors.toList()), + dialog -> dialog.show(getFM(), TAG) + )); + break; default: return super.onOptionsItemSelected(item); } @@ -328,9 +301,12 @@ 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(AppCompatResources + .getColorStateList(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..5afaea038 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java @@ -0,0 +1,354 @@ +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.StreamTypeUtil; +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 (!StreamTypeUtil.isAudio(infoItem.getStreamType())) { + 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 && !StreamTypeUtil.isLiveStream(infoItem.getStreamType())) { + 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/info_list/holder/StreamMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java index 79772a6a3..54d31ca57 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java @@ -11,12 +11,12 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.views.AnimatedProgressBar; import java.util.concurrent.TimeUnit; @@ -70,8 +70,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { } else { itemProgressView.setVisibility(View.GONE); } - } else if (item.getStreamType() == StreamType.LIVE_STREAM - || item.getStreamType() == StreamType.AUDIO_LIVE_STREAM) { + } else if (StreamTypeUtil.isLiveStream(item.getStreamType())) { itemDurationView.setText(R.string.duration_live); itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), R.color.live_duration_background_color)); @@ -96,9 +95,10 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { case VIDEO_STREAM: case LIVE_STREAM: case AUDIO_LIVE_STREAM: + case POST_LIVE_STREAM: + case POST_LIVE_AUDIO_STREAM: enableLongClick(item); break; - case FILE: case NONE: default: disableLongClick(); @@ -114,7 +114,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { final StreamStateEntity state = historyRecordManager.loadStreamState(infoItem).blockingGet()[0]; if (state != null && item.getDuration() > 0 - && item.getStreamType() != StreamType.LIVE_STREAM) { + && !StreamTypeUtil.isLiveStream(item.getStreamType())) { itemProgressView.setMax((int) item.getDuration()); if (itemProgressView.getVisibility() == View.VISIBLE) { itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS diff --git a/app/src/main/java/org/schabi/newpipe/ktx/View.kt b/app/src/main/java/org/schabi/newpipe/ktx/View.kt index 8dcc9d85c..ace1dbf7e 100644 --- a/app/src/main/java/org/schabi/newpipe/ktx/View.kt +++ b/app/src/main/java/org/schabi/newpipe/ktx/View.kt @@ -300,14 +300,7 @@ private fun View.animateLightSlideAndAlpha(enterOrExit: Boolean, duration: Long, } } -fun View.slideUp( - duration: Long, - delay: Long, - @FloatRange(from = 0.0, to = 1.0) translationPercent: Float -) { - slideUp(duration, delay, translationPercent, null) -} - +@JvmOverloads fun View.slideUp( duration: Long, delay: Long = 0L, 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..b291aa035 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 @@ -25,7 +25,6 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.graphics.Typeface -import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable import android.os.Bundle import android.os.Parcelable @@ -37,7 +36,6 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.Button -import androidx.annotation.AttrRes import androidx.annotation.Nullable import androidx.appcompat.app.AlertDialog import androidx.appcompat.content.res.AppCompatResources @@ -50,7 +48,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 +65,22 @@ 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.resolveDrawable 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 +137,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 +350,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 +371,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 +391,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 +447,7 @@ class FeedFragment : BaseStateFragment() { }.subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( - { - subscriptionEntity -> + { subscriptionEntity -> handleFeedNotAvailable( subscriptionEntity, t.cause, @@ -629,19 +578,6 @@ class FeedFragment : BaseStateFragment() { lastNewItemsCount = highlightCount } - private fun resolveDrawable(context: Context, @AttrRes attrResId: Int): Drawable? { - return androidx.core.content.ContextCompat.getDrawable( - context, - android.util.TypedValue().apply { - context.theme.resolveAttribute( - attrResId, - this, - true - ) - }.resourceId - ) - } - private fun showNewItemsLoaded() { tryGetNewItemsLoadedButton()?.clearAnimation() tryGetNewItemsLoadedButton() 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/item/StreamItem.kt b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt index 217e3f3e3..96d395aa5 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt @@ -14,6 +14,8 @@ import org.schabi.newpipe.databinding.ListStreamItemBinding import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_STREAM import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM +import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_AUDIO_STREAM +import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_STREAM import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.PicassoHelper @@ -109,7 +111,7 @@ data class StreamItem( } override fun isLongClickable() = when (stream.streamType) { - AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM -> true + AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM, POST_LIVE_STREAM, POST_LIVE_AUDIO_STREAM -> true else -> false } 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/HistoryRecordManager.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java index 45445cf58..19f7afce5 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java @@ -128,13 +128,11 @@ public class HistoryRecordManager { // Add a history entry final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId); - if (latestEntry != null) { - streamHistoryTable.delete(latestEntry); - latestEntry.setAccessDate(currentTime); - latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1); - return streamHistoryTable.insert(latestEntry); + if (latestEntry == null) { + // never actually viewed: add history entry but with 0 views + return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 0)); } else { - return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime)); + return 0L; } })).subscribeOn(Schedulers.io()); } @@ -155,7 +153,8 @@ public class HistoryRecordManager { latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1); return streamHistoryTable.insert(latestEntry); } else { - return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime)); + // just viewed for the first time: set 1 view + return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 1)); } })).subscribeOn(Schedulers.io()); } 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/holder/LocalPlaylistStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java index 561cde560..d39758326 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java @@ -11,12 +11,12 @@ import androidx.core.content.ContextCompat; import org.schabi.newpipe.R; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; -import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.views.AnimatedProgressBar; import java.time.format.DateTimeFormatter; @@ -59,7 +59,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder { itemVideoTitleView.setText(item.getStreamEntity().getTitle()); itemAdditionalDetailsView.setText(Localization .concatenateStrings(item.getStreamEntity().getUploader(), - NewPipe.getNameOfService(item.getStreamEntity().getServiceId()))); + ServiceHelper.getNameOfServiceById(item.getStreamEntity().getServiceId()))); if (item.getStreamEntity().getDuration() > 0) { itemDurationView.setText(Localization diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java index d2fe8b40f..0d88eecba 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java @@ -11,12 +11,12 @@ import androidx.core.content.ContextCompat; import org.schabi.newpipe.R; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.stream.StreamStatisticsEntry; -import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.views.AnimatedProgressBar; import java.time.format.DateTimeFormatter; @@ -70,11 +70,12 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder { private String getStreamInfoDetailLine(final StreamStatisticsEntry entry, final DateTimeFormatter dateTimeFormatter) { - final String watchCount = Localization - .shortViewCount(itemBuilder.getContext(), entry.getWatchCount()); - final String uploadDate = dateTimeFormatter.format(entry.getLatestAccessDate()); - final String serviceName = NewPipe.getNameOfService(entry.getStreamEntity().getServiceId()); - return Localization.concatenateStrings(watchCount, uploadDate, serviceName); + return Localization.concatenateStrings( + // watchCount + Localization.shortViewCount(itemBuilder.getContext(), entry.getWatchCount()), + dateTimeFormatter.format(entry.getLatestAccessDate()), + // serviceName + ServiceHelper.getNameOfServiceById(entry.getStreamEntity().getServiceId())); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java index 440353ac7..70987a6fc 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java @@ -5,11 +5,11 @@ import android.view.ViewGroup; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; -import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.ServiceHelper; import java.time.format.DateTimeFormatter; @@ -39,9 +39,9 @@ public class RemotePlaylistItemHolder extends PlaylistItemHolder { // Here is where the uploader name is set in the bookmarked playlists library if (!TextUtils.isEmpty(item.getUploader())) { itemUploaderView.setText(Localization.concatenateStrings(item.getUploader(), - NewPipe.getNameOfService(item.getServiceId()))); + ServiceHelper.getNameOfServiceById(item.getServiceId()))); } else { - itemUploaderView.setText(NewPipe.getNameOfService(item.getServiceId())); + itemUploaderView.setText(ServiceHelper.getNameOfServiceById(item.getServiceId())); } PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView); 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..6023d4b10 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, @@ -424,9 +419,11 @@ 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/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt index 008228083..4295424e6 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt @@ -1,24 +1,24 @@ package org.schabi.newpipe.local.subscription import android.app.Activity -import android.content.BroadcastReceiver import android.content.Context import android.content.DialogInterface import android.content.Intent -import android.content.IntentFilter import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater +import android.view.MenuItem +import android.view.SubMenu import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.lifecycle.ViewModelProvider -import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.recyclerview.widget.GridLayoutManager import com.xwray.groupie.Group import com.xwray.groupie.GroupAdapter @@ -34,6 +34,7 @@ import org.schabi.newpipe.databinding.FeedItemCarouselBinding import org.schabi.newpipe.databinding.FragmentSubscriptionBinding import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.channel.ChannelInfoItem import org.schabi.newpipe.fragments.BaseStateFragment import org.schabi.newpipe.ktx.animate @@ -45,13 +46,10 @@ import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem import org.schabi.newpipe.local.subscription.item.FeedGroupAddItem import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem -import org.schabi.newpipe.local.subscription.item.FeedImportExportItem import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem.Companion.PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService -import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE_ACTION import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.IMPORT_COMPLETE_ACTION import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE @@ -59,6 +57,7 @@ import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard import org.schabi.newpipe.streams.io.StoredFileHelper import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.OnClickGesture +import org.schabi.newpipe.util.ServiceHelper import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountChannels import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout import org.schabi.newpipe.util.external_communication.ShareUtils @@ -74,12 +73,9 @@ class SubscriptionFragment : BaseStateFragment() { private lateinit var subscriptionManager: SubscriptionManager private val disposables: CompositeDisposable = CompositeDisposable() - private var subscriptionBroadcastReceiver: BroadcastReceiver? = null - private val groupAdapter = GroupAdapter>() private val feedGroupsSection = Section() private var feedGroupsCarousel: FeedGroupCarouselItem? = null - private lateinit var importExportItem: FeedImportExportItem private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem private val subscriptionsSection = Section() @@ -91,12 +87,10 @@ class SubscriptionFragment : BaseStateFragment() { @State @JvmField var itemsListState: Parcelable? = null + @State @JvmField var feedGroupsListState: Parcelable? = null - @State - @JvmField - var importExportItemExpandedState: Boolean? = null init { setHasOptionsMenu(true) @@ -120,20 +114,10 @@ class SubscriptionFragment : BaseStateFragment() { return inflater.inflate(R.layout.fragment_subscription, container, false) } - override fun onResume() { - super.onResume() - setupBroadcastReceiver() - } - override fun onPause() { super.onPause() itemsListState = binding.itemsList.layoutManager?.onSaveInstanceState() feedGroupsListState = feedGroupsCarousel?.onSaveInstanceState() - importExportItemExpandedState = importExportItem.isExpanded - - if (subscriptionBroadcastReceiver != null && activity != null) { - LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!) - } } override fun onDestroy() { @@ -150,28 +134,61 @@ class SubscriptionFragment : BaseStateFragment() { activity.supportActionBar?.setDisplayShowTitleEnabled(true) activity.supportActionBar?.setTitle(R.string.tab_subscriptions) + + buildImportExportMenu(menu) } - private fun setupBroadcastReceiver() { - if (activity == null) return + private fun buildImportExportMenu(menu: Menu) { + // -- Import -- + val importSubMenu = menu.addSubMenu(R.string.import_from) - if (subscriptionBroadcastReceiver != null) { - LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!) - } + addMenuItemToSubmenu(importSubMenu, R.string.previous_export) { onImportPreviousSelected() } + .setIcon(R.drawable.ic_backup) - val filters = IntentFilter() - filters.addAction(EXPORT_COMPLETE_ACTION) - filters.addAction(IMPORT_COMPLETE_ACTION) - subscriptionBroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - _binding?.itemsList?.post { - importExportItem.isExpanded = false - importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS) - } + for (service in ServiceList.all()) { + val subscriptionExtractor = service.subscriptionExtractor ?: continue + + val supportedSources = subscriptionExtractor.supportedSources + if (supportedSources.isEmpty()) continue + + addMenuItemToSubmenu(importSubMenu, service.serviceInfo.name) { + onImportFromServiceSelected(service.serviceId) } + .setIcon(ServiceHelper.getIcon(service.serviceId)) } - LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver!!, filters) + // -- Export -- + val exportSubMenu = menu.addSubMenu(R.string.export_to) + + addMenuItemToSubmenu(exportSubMenu, R.string.file) { onExportSelected() } + .setIcon(R.drawable.ic_save) + } + + private fun addMenuItemToSubmenu( + subMenu: SubMenu, + @StringRes title: Int, + onClick: Runnable + ): MenuItem { + return setClickListenerToMenuItem(subMenu.add(title), onClick) + } + + private fun addMenuItemToSubmenu( + subMenu: SubMenu, + title: String, + onClick: Runnable + ): MenuItem { + return setClickListenerToMenuItem(subMenu.add(title), onClick) + } + + private fun setClickListenerToMenuItem( + menuItem: MenuItem, + onClick: Runnable + ): MenuItem { + menuItem.setOnMenuItemClickListener { _ -> + onClick.run() + true + } + return menuItem } private fun onImportFromServiceSelected(serviceId: Int) { @@ -263,13 +280,14 @@ class SubscriptionFragment : BaseStateFragment() { subscriptionsSection.setPlaceholder(EmptyPlaceholderItem()) subscriptionsSection.setHideWhenEmpty(true) - importExportItem = FeedImportExportItem( - { onImportPreviousSelected() }, - { onImportFromServiceSelected(it) }, - { onExportSelected() }, - importExportItemExpandedState ?: false + groupAdapter.add( + Section( + HeaderWithMenuItem( + getString(R.string.tab_subscriptions) + ), + listOf(subscriptionsSection) + ) ) - groupAdapter.add(Section(importExportItem, listOf(subscriptionsSection))) } override fun initViews(rootView: View, savedInstanceState: Bundle?) { @@ -371,13 +389,6 @@ class SubscriptionFragment : BaseStateFragment() { subscriptionsSection.update(result.subscriptions) subscriptionsSection.setHideWhenEmpty(false) - if (result.subscriptions.isEmpty() && importExportItemExpandedState == null) { - binding.itemsList.post { - importExportItem.isExpanded = true - importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS) - } - } - if (itemsListState != null) { binding.itemsList.layoutManager?.onRestoreInstanceState(itemsListState) itemsListState = null 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/SubscriptionsImportFragment.java b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java index 4737fa14f..56972b60d 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java @@ -1,5 +1,11 @@ package org.schabi.newpipe.local.subscription; +import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL; +import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE; +import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE; +import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE; +import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE; + import android.app.Activity; import android.content.Intent; import android.os.Bundle; @@ -40,12 +46,6 @@ import java.util.List; import icepick.State; -import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE; - public class SubscriptionsImportFragment extends BaseFragment { @State int currentServiceId = Constants.NO_SERVICE_ID; @@ -89,7 +89,7 @@ public class SubscriptionsImportFragment extends BaseFragment { if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) { ErrorUtil.showSnackbar(activity, new ErrorInfo(new String[]{}, UserAction.SUBSCRIPTION_IMPORT_EXPORT, - NewPipe.getNameOfService(currentServiceId), + ServiceHelper.getNameOfServiceById(currentServiceId), "Service does not support importing subscriptions", R.string.general_error)); activity.finish(); diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt index 851e84f9f..e96328961 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt @@ -1,7 +1,6 @@ package org.schabi.newpipe.local.subscription.dialog import android.app.Dialog -import android.content.res.ColorStateList import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater @@ -9,7 +8,7 @@ import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import android.widget.Toast -import androidx.core.content.ContextCompat +import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.getSystemService import androidx.core.os.bundleOf import androidx.core.view.isGone @@ -127,7 +126,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable { if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP) { // KitKat doesn't apply container's theme to content - val contrastColor = ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.contrastColor)) + val contrastColor = AppCompatResources.getColorStateList(requireContext(), R.color.contrastColor) searchLayoutBinding.toolbarSearchEditText.setTextColor(contrastColor) searchLayoutBinding.toolbarSearchEditText.setHintTextColor(contrastColor.withAlpha(128)) ImageViewCompat.setImageTintList(searchLayoutBinding.toolbarSearchClearIcon, contrastColor) 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/item/FeedImportExportItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedImportExportItem.kt deleted file mode 100644 index aacfc77ad..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedImportExportItem.kt +++ /dev/null @@ -1,122 +0,0 @@ -package org.schabi.newpipe.local.subscription.item - -import android.graphics.Color -import android.graphics.PorterDuff -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.annotation.DrawableRes -import com.xwray.groupie.viewbinding.BindableItem -import com.xwray.groupie.viewbinding.GroupieViewHolder -import org.schabi.newpipe.R -import org.schabi.newpipe.databinding.FeedImportExportGroupBinding -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.exceptions.ExtractionException -import org.schabi.newpipe.ktx.animateRotation -import org.schabi.newpipe.util.ServiceHelper -import org.schabi.newpipe.util.ThemeHelper -import org.schabi.newpipe.views.CollapsibleView - -class FeedImportExportItem( - val onImportPreviousSelected: () -> Unit, - val onImportFromServiceSelected: (Int) -> Unit, - val onExportSelected: () -> Unit, - var isExpanded: Boolean = false -) : BindableItem() { - companion object { - const val REFRESH_EXPANDED_STATUS = 123 - } - - override fun bind(viewBinding: FeedImportExportGroupBinding, position: Int, payloads: MutableList) { - if (payloads.contains(REFRESH_EXPANDED_STATUS)) { - viewBinding.importExportOptions.apply { if (isExpanded) expand() else collapse() } - return - } - - super.bind(viewBinding, position, payloads) - } - - override fun getLayout(): Int = R.layout.feed_import_export_group - - override fun bind(viewBinding: FeedImportExportGroupBinding, position: Int) { - if (viewBinding.importFromOptions.childCount == 0) setupImportFromItems(viewBinding.importFromOptions) - if (viewBinding.exportToOptions.childCount == 0) setupExportToItems(viewBinding.exportToOptions) - - expandIconListener?.let { viewBinding.importExportOptions.removeListener(it) } - expandIconListener = CollapsibleView.StateListener { newState -> - viewBinding.importExportExpandIcon.animateRotation( - 250, if (newState == CollapsibleView.COLLAPSED) 0 else 180 - ) - } - - viewBinding.importExportOptions.currentState = if (isExpanded) CollapsibleView.EXPANDED else CollapsibleView.COLLAPSED - viewBinding.importExportExpandIcon.rotation = if (isExpanded) 180F else 0F - viewBinding.importExportOptions.ready() - - viewBinding.importExportOptions.addListener(expandIconListener) - viewBinding.importExport.setOnClickListener { - viewBinding.importExportOptions.switchState() - isExpanded = viewBinding.importExportOptions.currentState == CollapsibleView.EXPANDED - } - } - - override fun unbind(viewHolder: GroupieViewHolder) { - super.unbind(viewHolder) - expandIconListener?.let { viewHolder.binding.importExportOptions.removeListener(it) } - expandIconListener = null - } - - override fun initializeViewBinding(view: View) = FeedImportExportGroupBinding.bind(view) - - private var expandIconListener: CollapsibleView.StateListener? = null - - private fun addItemView(title: String, @DrawableRes icon: Int, container: ViewGroup): View { - val itemRoot = View.inflate(container.context, R.layout.subscription_import_export_item, null) - val titleView = itemRoot.findViewById(android.R.id.text1) - val iconView = itemRoot.findViewById(android.R.id.icon1) - - titleView.text = title - iconView.setImageResource(icon) - - container.addView(itemRoot) - return itemRoot - } - - private fun setupImportFromItems(listHolder: ViewGroup) { - val previousBackupItem = addItemView( - listHolder.context.getString(R.string.previous_export), - R.drawable.ic_backup, listHolder - ) - previousBackupItem.setOnClickListener { onImportPreviousSelected() } - - val iconColor = if (ThemeHelper.isLightThemeSelected(listHolder.context)) Color.BLACK else Color.WHITE - val services = listHolder.context.resources.getStringArray(R.array.service_list) - for (serviceName in services) { - try { - val service = NewPipe.getService(serviceName) - - val subscriptionExtractor = service.subscriptionExtractor ?: continue - - val supportedSources = subscriptionExtractor.supportedSources - if (supportedSources.isEmpty()) continue - - val itemView = addItemView(serviceName, ServiceHelper.getIcon(service.serviceId), listHolder) - val iconView = itemView.findViewById(android.R.id.icon1) - iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN) - - itemView.setOnClickListener { onImportFromServiceSelected(service.serviceId) } - } catch (e: ExtractionException) { - throw RuntimeException("Services array contains an entry that it's not a valid service name ($serviceName)", e) - } - } - } - - private fun setupExportToItems(listHolder: ViewGroup) { - val previousBackupItem = addItemView( - listHolder.context.getString(R.string.file), - R.drawable.ic_save, listHolder - ) - previousBackupItem.setOnClickListener { onExportSelected() } - } -} 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/LocalPlayer.java b/app/src/main/java/org/schabi/newpipe/player/LocalPlayer.java index 644788e69..2be37b295 100644 --- a/app/src/main/java/org/schabi/newpipe/player/LocalPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/LocalPlayer.java @@ -11,7 +11,6 @@ import androidx.preference.PreferenceManager; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.PlaybackParameters; -import com.google.android.exoplayer2.Player.EventListener; import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.source.MediaSource; @@ -36,7 +35,7 @@ import static org.schabi.newpipe.player.Player.STATE_PAUSED; import static org.schabi.newpipe.player.Player.STATE_PAUSED_SEEK; import static org.schabi.newpipe.player.Player.STATE_PLAYING; -public class LocalPlayer implements EventListener { +public class LocalPlayer implements com.google.android.exoplayer2.Player.Listener { private static final String TAG = "LocalPlayer"; private static final int PROGRESS_LOOP_INTERVAL_MILLIS = 500; @@ -325,6 +324,9 @@ public class LocalPlayer implements EventListener { case "preview": toastText = context .getString(R.string.sponsor_block_skip_preview_toast); + case "filler": + toastText = context + .getString(R.string.sponsor_block_skip_filler_toast); break; } 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 55524ebfb..a614c5ebe 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java @@ -1,5 +1,9 @@ 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; @@ -25,11 +29,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; @@ -44,13 +46,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; - import static org.schabi.newpipe.util.SponsorBlockUtils.markSegments; public final class PlayQueueActivity extends AppCompatActivity @@ -107,7 +102,10 @@ public final class PlayQueueActivity extends AppCompatActivity getMenuInflater().inflate(R.menu.menu_play_queue, m); getMenuInflater().inflate(R.menu.menu_play_queue_bg, m); onMaybeMuteChanged(); - onPlaybackParameterChanged(player.getPlaybackParameters()); + // to avoid null reference + if (player != null) { + onPlaybackParameterChanged(player.getPlaybackParameters()); + } return true; } @@ -133,7 +131,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(); @@ -453,24 +451,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 //////////////////////////////////////////////////////////////////////////// @@ -634,7 +614,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 6e02a62a3..8e40ef7bf 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,7 @@ import android.provider.Settings; import android.util.DisplayMetrics; import android.util.Log; import android.util.TypedValue; -import android.view.ContextThemeWrapper; +import android.view.GestureDetector; import android.view.Gravity; import android.view.KeyEvent; import android.view.LayoutInflater; @@ -89,7 +104,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; @@ -100,31 +114,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.SeekParameters; -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; @@ -135,22 +152,25 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.squareup.picasso.Picasso; 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; @@ -160,10 +180,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; @@ -175,8 +196,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; @@ -192,13 +213,14 @@ import org.schabi.newpipe.util.VideoSegment; 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.HashSet; import java.util.List; import java.util.Objects; import java.util.Set; 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; @@ -277,19 +299,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; @@ -373,7 +395,7 @@ public final class Player implements private static final float MAX_GESTURE_LENGTH = 0.75f; private int maxGestureLength; // scaled - private GestureDetectorCompat gestureDetector; + private GestureDetector gestureDetector; private PlayerGestureListener playerGestureListener; /*////////////////////////////////////////////////////////////////////////// @@ -420,8 +442,8 @@ public final class Player implements setupBroadcastReceiver(); - trackSelector = new CustomTrackSelector(context, PlayerHelper.getQualitySelector()); - final PlayerDataSource dataSource = new PlayerDataSource(context, DownloaderImpl.USER_AGENT, + trackSelector = new DefaultTrackSelector(context, PlayerHelper.getQualitySelector()); + final PlayerDataSource dataSource = new PlayerDataSource(context, new DefaultBandwidthMeter.Builder(context).build()); loadController = new LoadController(); renderFactory = new DefaultRenderersFactory(context); @@ -503,7 +525,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(); @@ -536,21 +558,29 @@ 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); playerGestureListener = new PlayerGestureListener(this, service); - gestureDetector = new GestureDetectorCompat(context, playerGestureListener); + gestureDetector = new GestureDetector(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); @@ -682,6 +712,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); @@ -1649,8 +1680,7 @@ public final class Player implements } public boolean getPlaybackSkipSilence() { - return !exoPlayerIsNull() && simpleExoPlayer.getAudioComponent() != null - && simpleExoPlayer.getAudioComponent().getSkipSilenceEnabled(); + return !exoPlayerIsNull() && simpleExoPlayer.getSkipSilenceEnabled(); } public PlaybackParameters getPlaybackParameters() { @@ -1676,9 +1706,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 @@ -1745,25 +1773,13 @@ public final class Player implements if (exoPlayerIsNull()) { return; } - // Use duration of currentItem for non-live streams, - // because HLS streams are fragmented - // and thus the whole duration is not available to the player - // TODO: revert #6307 when introducing proper HLS support - final int duration; - if (currentItem != null - && !StreamTypeUtil.isLiveStream(currentItem.getStreamType()) - ) { - // convert seconds to milliseconds - duration = (int) (currentItem.getDuration() * 1000); - } else { - duration = (int) simpleExoPlayer.getDuration(); - } + final int currentProgress = Math.max((int) simpleExoPlayer.getCurrentPosition(), 0); + onUpdateProgress( currentProgress, - duration, - simpleExoPlayer.getBufferedPercentage() - ); + (int) simpleExoPlayer.getDuration(), + simpleExoPlayer.getBufferedPercentage()); if (sponsorBlockMode == SponsorBlockMode.ENABLED && isPrepared) { final VideoSegment segment = getSkippableSegment(currentProgress); @@ -1826,6 +1842,9 @@ public final class Player implements case "preview": toastText = context .getString(R.string.sponsor_block_skip_preview_toast); + case "filler": + toastText = context + .getString(R.string.sponsor_block_skip_filler_toast); break; } @@ -2020,7 +2039,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); @@ -2034,11 +2053,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); @@ -2078,9 +2098,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 + "], " @@ -2089,7 +2129,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; } @@ -2104,8 +2144,6 @@ public final class Player implements } break; case com.google.android.exoplayer2.Player.STATE_READY: //3 - maybeUpdateCurrentMetadata(); - maybeCorrectSeekPosition(); if (!isPrepared) { isPrepared = true; onPrepared(playWhenReady); @@ -2122,18 +2160,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 @@ -2490,6 +2521,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 //////////////////////////////////////////////////////////////////////////*/ @@ -2521,28 +2578,61 @@ 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 + "]"); - } + 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; // we still have the same metadata, no need to do anything + } + final StreamInfo previousInfo = Optional.ofNullable(currentMetadata) + .flatMap(MediaItemTag::getMaybeStreamInfo).orElse(null); + currentMetadata = tag; - maybeUpdateCurrentMetadata(); - // force recreate notification to ensure seek bar is shown when preparation finishes - NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, true); + if (!currentMetadata.getErrors().isEmpty()) { + // new errors might have been added even if previousInfo == tag.getMaybeStreamInfo() + final ErrorInfo errorInfo = new ErrorInfo( + currentMetadata.getErrors(), + UserAction.PLAY_STREAM, + "Loading failed for [" + currentMetadata.getTitle() + + "]: " + currentMetadata.getStreamUrl(), + currentMetadata.getServiceId()); + ErrorUtil.createNotification(context, errorInfo); + } + + currentMetadata.getMaybeStreamInfo().ifPresent(info -> { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onEvents() update stream info: " + info.getName()); + } + if (previousInfo == null || !previousInfo.getUrl().equals(info.getUrl())) { + // only update with the new stream info if it has actually changed + 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 @@ -2555,11 +2645,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) { @@ -2567,13 +2661,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; } @@ -2586,16 +2680,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 @@ -2605,7 +2698,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 @@ -2620,94 +2713,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 @@ -2754,7 +2856,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; @@ -2766,20 +2868,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 @@ -2801,8 +2902,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 + "], " @@ -2819,28 +2919,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 + "]"); @@ -3002,24 +3080,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) { @@ -3032,7 +3108,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) @@ -3045,10 +3121,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 @@ -3059,8 +3134,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()); } @@ -3072,12 +3146,10 @@ public final class Player implements setBlockSponsorsButton(binding.switchSponsorBlocking); - 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); @@ -3087,9 +3159,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(); @@ -3119,39 +3189,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 @@ -3159,7 +3211,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); } @@ -3170,14 +3222,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 @@ -3197,14 +3249,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()); @@ -3221,7 +3273,7 @@ public final class Player implements return; } - if (playQueue.getIndex() == index && simpleExoPlayer.getCurrentWindowIndex() == index) { + if (playQueue.getIndex() == index && simpleExoPlayer.getCurrentMediaItemIndex() == index) { seekToDefault(); } else { saveStreamProgressState(); @@ -3246,6 +3298,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(); @@ -3283,6 +3336,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(); @@ -3305,12 +3359,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()); } @@ -3330,6 +3383,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(); } } @@ -3357,7 +3413,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) { @@ -3413,7 +3471,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() { @@ -3428,10 +3506,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); @@ -3441,6 +3519,7 @@ public final class Player implements switch (info.getStreamType()) { case AUDIO_STREAM: + case POST_LIVE_AUDIO_STREAM: binding.surfaceView.setVisibility(View.GONE); binding.endScreen.setVisibility(View.VISIBLE); binding.playbackEndTime.setVisibility(View.VISIBLE); @@ -3459,12 +3538,17 @@ public final class Player implements break; case VIDEO_STREAM: - if (info.getVideoStreams().size() + info.getVideoOnlyStreams().size() == 0) { + case POST_LIVE_STREAM: + 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); @@ -3522,10 +3606,10 @@ public final class Player implements for (int i = 0; i < availableStreams.size(); i++) { final VideoStream videoStream = availableStreams.get(i); qualityPopupMenu.getMenu().add(POPUP_MENU_ID_QUALITY, i, Menu.NONE, MediaFormat - .getNameById(videoStream.getFormatId()) + " " + videoStream.resolution); + .getNameById(videoStream.getFormatId()) + " " + videoStream.getResolution()); } if (getSelectedVideoStream() != null) { - binding.qualityTextView.setText(getSelectedVideoStream().resolution); + binding.qualityTextView.setText(getSelectedVideoStream().getResolution()); } qualityPopupMenu.setOnMenuItemClickListener(this); qualityPopupMenu.setOnDismissListener(this); @@ -3551,17 +3635,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, @@ -3584,30 +3658,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)); + } } /** @@ -3629,7 +3727,7 @@ public final class Player implements } saveStreamProgressState(); //TODO added, check if good - final String newResolution = availableStreams.get(menuItemIndex).resolution; + final String newResolution = availableStreams.get(menuItemIndex).getResolution(); setRecovery(); setPlaybackQuality(newResolution); reloadPlayQueueManager(); @@ -3657,7 +3755,7 @@ public final class Player implements } isSomePopupMenuVisible = false; //TODO check if this works if (getSelectedVideoStream() != null) { - binding.qualityTextView.setText(getSelectedVideoStream().resolution); + binding.qualityTextView.setText(getSelectedVideoStream().getResolution()); } if (isPlaying()) { hideControls(DEFAULT_CONTROLS_DURATION, 0); @@ -3665,37 +3763,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"); @@ -3734,40 +3801,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); @@ -3800,11 +3870,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(); @@ -3816,18 +3882,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()) { @@ -3858,23 +3912,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 @@ -3891,12 +3955,12 @@ public final class Player implements final String toastText; if (getSponsorBlockMode() == SponsorBlockMode.IGNORE) { - uploaderWhitelist.remove(currentMetadata.getMetadata().getUploaderName()); + uploaderWhitelist.remove(currentMetadata.getUploaderName()); setSponsorBlockMode(SponsorBlockMode.ENABLED); toastText = context .getString(R.string.sponsor_block_uploader_removed_from_whitelist_toast); } else { - uploaderWhitelist.add(currentMetadata.getMetadata().getUploaderName()); + uploaderWhitelist.add(currentMetadata.getUploaderName()); setSponsorBlockMode(SponsorBlockMode.IGNORE); toastText = context .getString(R.string.sponsor_block_uploader_added_to_whitelist_toast); @@ -3923,6 +3987,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: @@ -3936,8 +4004,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; } @@ -3945,15 +4014,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; } @@ -3998,10 +4065,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 @@ -4264,12 +4331,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() { @@ -4304,19 +4373,127 @@ 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 { + if (StreamTypeUtil.isAudio(info.getStreamType())) { + // 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}, an + * {@link StreamType#AUDIO_LIVE_STREAM audio live stream}, or a + * {@link StreamType#POST_LIVE_AUDIO_STREAM ended 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#VIDEO_STREAM video stream}, an + * {@link StreamType#POST_LIVE_STREAM ended 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(); + final boolean isStreamTypeAudio = StreamTypeUtil.isAudio(streamType); + + if (videoRendererIndex == RENDERER_UNAVAILABLE && !isStreamTypeAudio) { + 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 (isStreamTypeAudio || (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, a live stream or an ended live stream + return !StreamTypeUtil.isVideo(streamType); + } + + // Other cases: the play queue manager reload is needed + return true; } //endregion @@ -4326,6 +4503,10 @@ public final class Player implements //////////////////////////////////////////////////////////////////////////*/ //region Getters + private Optional getCurrentStreamInfo() { + return Optional.ofNullable(currentMetadata).flatMap(MediaItemTag::getMaybeStreamInfo); + } + public int getCurrentState() { return currentState; } @@ -4335,8 +4516,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() { @@ -4353,8 +4533,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); @@ -4405,7 +4585,7 @@ public final class Player implements return audioReactor; } - public GestureDetectorCompat getGestureDetector() { + public GestureDetector getGestureDetector() { return gestureDetector; } @@ -4426,6 +4606,10 @@ public final class Player implements return isSomePopupMenuVisible; } + public void setSomePopupMenuVisible(final boolean somePopupMenuVisible) { + isSomePopupMenuVisible = somePopupMenuVisible; + } + public ImageButton getPlayPauseButton() { return binding.playPauseButton; } @@ -4505,8 +4689,12 @@ public final class Player implements } public PlayQueueAdapter getPlayQueueAdapter() { + return playQueueAdapter; } + public PlayerBinding getBinding() { + return binding; + } //endregion @@ -4520,22 +4708,18 @@ public final class Player implements Log.d(TAG, "onBlockingSponsorsButtonClicked() called"); } - setBlockSponsorsButton(binding.switchSponsorBlocking); - switch (sponsorBlockMode) { case DISABLED: sponsorBlockMode = SponsorBlockMode.ENABLED; - Toast.makeText(context, R.string.sponsor_block_enabled_toast, Toast.LENGTH_SHORT) - .show(); break; case ENABLED: sponsorBlockMode = SponsorBlockMode.DISABLED; - Toast.makeText(context, R.string.sponsor_block_disabled_toast, Toast.LENGTH_SHORT) - .show(); break; case IGNORE: // ignored } + + setBlockSponsorsButton(binding.switchSponsorBlocking); } public SponsorBlockMode getSponsorBlockMode() { @@ -4612,24 +4796,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/datasource/NonUriHlsDataSourceFactory.java b/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java new file mode 100644 index 000000000..676443a9c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java @@ -0,0 +1,136 @@ +package org.schabi.newpipe.player.datasource; + +import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; + +import androidx.annotation.NonNull; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.source.hls.HlsDataSourceFactory; +import com.google.android.exoplayer2.upstream.ByteArrayDataSource; +import com.google.android.exoplayer2.upstream.DataSource; + +import java.nio.charset.StandardCharsets; + +/** + * A {@link HlsDataSourceFactory} which allows playback of non-URI media HLS playlists for + * {@link com.google.android.exoplayer2.source.hls.HlsMediaSource HlsMediaSource}s. + * + *

+ * If media requests are relative, the URI from which the manifest comes from (either the + * manifest URI (preferred) or the master URI (if applicable)) must be returned, otherwise the + * content will be not playable, as it will be an invalid URL, or it may be treat as something + * unexpected, for instance as a file for + * {@link com.google.android.exoplayer2.upstream.DefaultDataSource DefaultDataSource}s. + *

+ * + *

+ * See {@link #createDataSource(int)} for changes and implementation details. + *

+ */ +public final class NonUriHlsDataSourceFactory implements HlsDataSourceFactory { + + /** + * Builder class of {@link NonUriHlsDataSourceFactory} instances. + */ + public static final class Builder { + private DataSource.Factory dataSourceFactory; + private String playlistString; + + /** + * Set the {@link DataSource.Factory} which will be used to create non manifest contents + * {@link DataSource}s. + * + * @param dataSourceFactoryForNonManifestContents the {@link DataSource.Factory} which will + * be used to create non manifest contents + * {@link DataSource}s, which cannot be null + */ + public void setDataSourceFactory( + @NonNull final DataSource.Factory dataSourceFactoryForNonManifestContents) { + this.dataSourceFactory = dataSourceFactoryForNonManifestContents; + } + + /** + * Set the HLS playlist which will be used for manifests requests. + * + * @param hlsPlaylistString the string which correspond to the response of the HLS + * manifest, which cannot be null or empty + */ + public void setPlaylistString(@NonNull final String hlsPlaylistString) { + this.playlistString = hlsPlaylistString; + } + + /** + * Create a new {@link NonUriHlsDataSourceFactory} with the given data source factory and + * the given HLS playlist. + * + * @return a {@link NonUriHlsDataSourceFactory} + * @throws IllegalArgumentException if the data source factory is null or if the HLS + * playlist string set is null or empty + */ + @NonNull + public NonUriHlsDataSourceFactory build() { + if (dataSourceFactory == null) { + throw new IllegalArgumentException( + "No DataSource.Factory valid instance has been specified."); + } + + if (isNullOrEmpty(playlistString)) { + throw new IllegalArgumentException("No HLS valid playlist has been specified."); + } + + return new NonUriHlsDataSourceFactory(dataSourceFactory, + playlistString.getBytes(StandardCharsets.UTF_8)); + } + } + + private final DataSource.Factory dataSourceFactory; + private final byte[] playlistStringByteArray; + + /** + * Create a {@link NonUriHlsDataSourceFactory} instance. + * + * @param dataSourceFactory the {@link DataSource.Factory} which will be used to build + * non manifests {@link DataSource}s, which must not be null + * @param playlistStringByteArray a byte array of the HLS playlist, which must not be null + */ + private NonUriHlsDataSourceFactory(@NonNull final DataSource.Factory dataSourceFactory, + @NonNull final byte[] playlistStringByteArray) { + this.dataSourceFactory = dataSourceFactory; + this.playlistStringByteArray = playlistStringByteArray; + } + + /** + * Create a {@link DataSource} for the given data type. + * + *

+ * Contrary to {@link com.google.android.exoplayer2.source.hls.DefaultHlsDataSourceFactory + * ExoPlayer's default implementation}, this implementation is not always using the + * {@link DataSource.Factory} passed to the + * {@link com.google.android.exoplayer2.source.hls.HlsMediaSource.Factory + * HlsMediaSource.Factory} constructor, only when it's not + * {@link C#DATA_TYPE_MANIFEST the manifest type}. + *

+ * + *

+ * This change allow playback of non-URI HLS contents, when the manifest is not a master + * manifest/playlist (otherwise, endless loops should be encountered because the + * {@link DataSource}s created for media playlists should use the master playlist response + * instead). + *

+ * + * @param dataType the data type for which the {@link DataSource} will be used, which is one of + * {@link C} {@code .DATA_TYPE_*} constants + * @return a {@link DataSource} for the given data type + */ + @NonNull + @Override + public DataSource createDataSource(final int dataType) { + // The manifest is already downloaded and provided with playlistStringByteArray, so we + // don't need to download it again and we can use a ByteArrayDataSource instead + if (dataType == C.DATA_TYPE_MANIFEST) { + return new ByteArrayDataSource(playlistStringByteArray); + } + + return dataSourceFactory.createDataSource(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java new file mode 100644 index 000000000..c9abe65f6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java @@ -0,0 +1,1014 @@ +/* + * Based on ExoPlayer's DefaultHttpDataSource, version 2.17.1. + * + * Original source code copyright (C) 2016 The Android Open Source Project, licensed under the + * Apache License, Version 2.0. + */ + +package org.schabi.newpipe.player.datasource; + +import static com.google.android.exoplayer2.upstream.DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS; +import static com.google.android.exoplayer2.upstream.DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS; +import static com.google.android.exoplayer2.upstream.HttpUtil.buildRangeRequestHeader; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getAndroidUserAgent; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getIosUserAgent; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isAndroidStreamingUrl; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isIosStreamingUrl; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebStreamingUrl; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isTvHtml5SimplyEmbeddedPlayerStreamingUrl; +import static java.lang.Math.min; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.PlaybackException; +import com.google.android.exoplayer2.upstream.BaseDataSource; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSourceException; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.DataSpec.HttpMethod; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.android.exoplayer2.upstream.HttpUtil; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Predicate; +import com.google.common.collect.ForwardingMap; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Sets; +import com.google.common.net.HttpHeaders; + +import org.schabi.newpipe.DownloaderImpl; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.lang.reflect.Method; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.NoRouteToHostException; +import java.net.URL; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.zip.GZIPInputStream; + +/** + * An {@link HttpDataSource} that uses Android's {@link HttpURLConnection}, based on + * {@link com.google.android.exoplayer2.upstream.DefaultHttpDataSource}, for YouTube streams. + * + *

+ * It adds more headers to {@code videoplayback} URLs, such as {@code Origin}, {@code Referer} + * (only where it's relevant) and also more parameters, such as {@code rn} and replaces the use of + * the {@code Range} header by the corresponding parameter ({@code range}), if enabled. + *

+ * + * There are many unused methods in this class because everything was copied from {@link + * com.google.android.exoplayer2.upstream.DefaultHttpDataSource} with as little changes as possible. + * SonarQube warnings were also suppressed for the same reason. + */ +@SuppressWarnings({"squid:S3011", "squid:S4738"}) +public final class YoutubeHttpDataSource extends BaseDataSource implements HttpDataSource { + + /** + * {@link DataSource.Factory} for {@link YoutubeHttpDataSource} instances. + */ + public static final class Factory implements HttpDataSource.Factory { + + private final RequestProperties defaultRequestProperties; + + @Nullable + private TransferListener transferListener; + @Nullable + private Predicate contentTypePredicate; + private int connectTimeoutMs; + private int readTimeoutMs; + private boolean allowCrossProtocolRedirects; + private boolean keepPostFor302Redirects; + + private boolean rangeParameterEnabled; + private boolean rnParameterEnabled; + + /** + * Creates an instance. + */ + public Factory() { + defaultRequestProperties = new RequestProperties(); + connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MILLIS; + readTimeoutMs = DEFAULT_READ_TIMEOUT_MILLIS; + } + + @NonNull + @Override + public Factory setDefaultRequestProperties( + @NonNull final Map defaultRequestPropertiesMap) { + defaultRequestProperties.clearAndSet(defaultRequestPropertiesMap); + return this; + } + + /** + * Sets the connect timeout, in milliseconds. + * + *

+ * The default is {@link DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS}. + *

+ * + * @param connectTimeoutMsValue The connect timeout, in milliseconds, that will be used. + * @return This factory. + */ + public Factory setConnectTimeoutMs(final int connectTimeoutMsValue) { + connectTimeoutMs = connectTimeoutMsValue; + return this; + } + + /** + * Sets the read timeout, in milliseconds. + * + *

The default is {@link DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS}. + * + * @param readTimeoutMsValue The connect timeout, in milliseconds, that will be used. + * @return This factory. + */ + public Factory setReadTimeoutMs(final int readTimeoutMsValue) { + readTimeoutMs = readTimeoutMsValue; + return this; + } + + /** + * Sets whether to allow cross protocol redirects. + * + *

The default is {@code false}. + * + * @param allowCrossProtocolRedirectsValue Whether to allow cross protocol redirects. + * @return This factory. + */ + public Factory setAllowCrossProtocolRedirects( + final boolean allowCrossProtocolRedirectsValue) { + allowCrossProtocolRedirects = allowCrossProtocolRedirectsValue; + return this; + } + + /** + * Sets whether the use of the {@code range} parameter instead of the {@code Range} header + * to request ranges of streams is enabled. + * + *

+ * Note that it must be not enabled on streams which are using a {@link + * com.google.android.exoplayer2.source.ProgressiveMediaSource}, as it will break playback + * for them (some exceptions may be thrown). + *

+ * + * @param rangeParameterEnabledValue whether the use of the {@code range} parameter instead + * of the {@code Range} header (must be only enabled when + * non-{@code ProgressiveMediaSource}s) + * @return This factory. + */ + public Factory setRangeParameterEnabled(final boolean rangeParameterEnabledValue) { + rangeParameterEnabled = rangeParameterEnabledValue; + return this; + } + + /** + * Sets whether the use of the {@code rn}, which stands for request number, parameter is + * enabled. + * + *

+ * Note that it should be not enabled on streams which are using {@code /} to delimit URLs + * parameters, such as the streams of HLS manifests. + *

+ * + * @param rnParameterEnabledValue whether the appending the {@code rn} parameter to + * {@code videoplayback} URLs + * @return This factory. + */ + public Factory setRnParameterEnabled(final boolean rnParameterEnabledValue) { + rnParameterEnabled = rnParameterEnabledValue; + return this; + } + + /** + * Sets a content type {@link Predicate}. If a content type is rejected by the predicate + * then a {@link HttpDataSource.InvalidContentTypeException} is thrown from + * {@link YoutubeHttpDataSource#open(DataSpec)}. + * + *

+ * The default is {@code null}. + *

+ * + * @param contentTypePredicateToSet The content type {@link Predicate}, or {@code null} to + * clear a predicate that was previously set. + * @return This factory. + */ + public Factory setContentTypePredicate( + @Nullable final Predicate contentTypePredicateToSet) { + this.contentTypePredicate = contentTypePredicateToSet; + return this; + } + + /** + * Sets the {@link TransferListener} that will be used. + * + *

The default is {@code null}. + * + *

See {@link DataSource#addTransferListener(TransferListener)}. + * + * @param transferListenerToUse The listener that will be used. + * @return This factory. + */ + public Factory setTransferListener( + @Nullable final TransferListener transferListenerToUse) { + this.transferListener = transferListenerToUse; + return this; + } + + /** + * Sets whether we should keep the POST method and body when we have HTTP 302 redirects for + * a POST request. + * + * @param keepPostFor302RedirectsValue Whether we should keep the POST method and body when + * we have HTTP 302 redirects for a POST request. + * @return This factory. + */ + public Factory setKeepPostFor302Redirects(final boolean keepPostFor302RedirectsValue) { + this.keepPostFor302Redirects = keepPostFor302RedirectsValue; + return this; + } + + @NonNull + @Override + public YoutubeHttpDataSource createDataSource() { + final YoutubeHttpDataSource dataSource = new YoutubeHttpDataSource( + connectTimeoutMs, + readTimeoutMs, + allowCrossProtocolRedirects, + rangeParameterEnabled, + rnParameterEnabled, + defaultRequestProperties, + contentTypePredicate, + keepPostFor302Redirects); + if (transferListener != null) { + dataSource.addTransferListener(transferListener); + } + return dataSource; + } + } + + private static final String TAG = YoutubeHttpDataSource.class.getSimpleName(); + private static final int MAX_REDIRECTS = 20; // Same limit as okhttp. + private static final int HTTP_STATUS_TEMPORARY_REDIRECT = 307; + private static final int HTTP_STATUS_PERMANENT_REDIRECT = 308; + private static final long MAX_BYTES_TO_DRAIN = 2048; + + private static final String RN_PARAMETER = "&rn="; + private static final String YOUTUBE_BASE_URL = "https://www.youtube.com"; + + private final boolean allowCrossProtocolRedirects; + private final boolean rangeParameterEnabled; + private final boolean rnParameterEnabled; + + private final int connectTimeoutMillis; + private final int readTimeoutMillis; + @Nullable + private final RequestProperties defaultRequestProperties; + private final RequestProperties requestProperties; + private final boolean keepPostFor302Redirects; + + @Nullable + private final Predicate contentTypePredicate; + @Nullable + private DataSpec dataSpec; + @Nullable + private HttpURLConnection connection; + @Nullable + private InputStream inputStream; + private boolean opened; + private int responseCode; + private long bytesToRead; + private long bytesRead; + + private long requestNumber; + + @SuppressWarnings("checkstyle:ParameterNumber") + private YoutubeHttpDataSource(final int connectTimeoutMillis, + final int readTimeoutMillis, + final boolean allowCrossProtocolRedirects, + final boolean rangeParameterEnabled, + final boolean rnParameterEnabled, + @Nullable final RequestProperties defaultRequestProperties, + @Nullable final Predicate contentTypePredicate, + final boolean keepPostFor302Redirects) { + super(true); + this.connectTimeoutMillis = connectTimeoutMillis; + this.readTimeoutMillis = readTimeoutMillis; + this.allowCrossProtocolRedirects = allowCrossProtocolRedirects; + this.rangeParameterEnabled = rangeParameterEnabled; + this.rnParameterEnabled = rnParameterEnabled; + this.defaultRequestProperties = defaultRequestProperties; + this.contentTypePredicate = contentTypePredicate; + this.requestProperties = new RequestProperties(); + this.keepPostFor302Redirects = keepPostFor302Redirects; + this.requestNumber = 0; + } + + @Override + @Nullable + public Uri getUri() { + return connection == null ? null : Uri.parse(connection.getURL().toString()); + } + + @Override + public int getResponseCode() { + return connection == null || responseCode <= 0 ? -1 : responseCode; + } + + @NonNull + @Override + public Map> getResponseHeaders() { + if (connection == null) { + return ImmutableMap.of(); + } + // connection.getHeaderFields() always contains a null key with a value like + // ["HTTP/1.1 200 OK"]. The response code is available from + // HttpURLConnection#getResponseCode() and the HTTP version is fixed when establishing the + // connection. + // DataSource#getResponseHeaders() doesn't allow null keys in the returned map, so we need + // to remove it. + // connection.getHeaderFields() returns a special unmodifiable case-insensitive Map + // so we can't just remove the null key or make a copy without the null key. Instead we + // wrap it in a ForwardingMap subclass that ignores and filters out null keys in the read + // methods. + return new NullFilteringHeadersMap(connection.getHeaderFields()); + } + + @Override + public void setRequestProperty(@NonNull final String name, @NonNull final String value) { + checkNotNull(name); + checkNotNull(value); + requestProperties.set(name, value); + } + + @Override + public void clearRequestProperty(@NonNull final String name) { + checkNotNull(name); + requestProperties.remove(name); + } + + @Override + public void clearAllRequestProperties() { + requestProperties.clear(); + } + + /** + * Opens the source to read the specified data. + */ + @Override + public long open(@NonNull final DataSpec dataSpecParameter) throws HttpDataSourceException { + this.dataSpec = dataSpecParameter; + bytesRead = 0; + bytesToRead = 0; + transferInitializing(dataSpecParameter); + + final HttpURLConnection httpURLConnection; + final String responseMessage; + try { + this.connection = makeConnection(dataSpec); + httpURLConnection = this.connection; + responseCode = httpURLConnection.getResponseCode(); + responseMessage = httpURLConnection.getResponseMessage(); + } catch (final IOException e) { + closeConnectionQuietly(); + throw HttpDataSourceException.createForIOException(e, dataSpec, + HttpDataSourceException.TYPE_OPEN); + } + + // Check for a valid response code. + if (responseCode < 200 || responseCode > 299) { + final Map> headers = httpURLConnection.getHeaderFields(); + if (responseCode == 416) { + final long documentSize = HttpUtil.getDocumentSize( + httpURLConnection.getHeaderField(HttpHeaders.CONTENT_RANGE)); + if (dataSpecParameter.position == documentSize) { + opened = true; + transferStarted(dataSpecParameter); + return dataSpecParameter.length != C.LENGTH_UNSET + ? dataSpecParameter.length + : 0; + } + } + + final InputStream errorStream = httpURLConnection.getErrorStream(); + byte[] errorResponseBody; + try { + errorResponseBody = errorStream != null + ? Util.toByteArray(errorStream) + : Util.EMPTY_BYTE_ARRAY; + } catch (final IOException e) { + errorResponseBody = Util.EMPTY_BYTE_ARRAY; + } + + closeConnectionQuietly(); + final IOException cause = responseCode == 416 ? new DataSourceException( + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE) + : null; + throw new InvalidResponseCodeException(responseCode, responseMessage, cause, headers, + dataSpec, errorResponseBody); + } + + // Check for a valid content type. + final String contentType = httpURLConnection.getContentType(); + if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) { + closeConnectionQuietly(); + throw new InvalidContentTypeException(contentType, dataSpecParameter); + } + + final long bytesToSkip; + if (!rangeParameterEnabled) { + // If we requested a range starting from a non-zero position and received a 200 rather + // than a 206, then the server does not support partial requests. We'll need to + // manually skip to the requested position. + bytesToSkip = responseCode == 200 && dataSpecParameter.position != 0 + ? dataSpecParameter.position + : 0; + } else { + bytesToSkip = 0; + } + + + // Determine the length of the data to be read, after skipping. + final boolean isCompressed = isCompressed(httpURLConnection); + if (!isCompressed) { + if (dataSpecParameter.length != C.LENGTH_UNSET) { + bytesToRead = dataSpecParameter.length; + } else { + final long contentLength = HttpUtil.getContentLength( + httpURLConnection.getHeaderField(HttpHeaders.CONTENT_LENGTH), + httpURLConnection.getHeaderField(HttpHeaders.CONTENT_RANGE)); + bytesToRead = contentLength != C.LENGTH_UNSET + ? (contentLength - bytesToSkip) + : C.LENGTH_UNSET; + } + } else { + // Gzip is enabled. If the server opts to use gzip then the content length in the + // response will be that of the compressed data, which isn't what we want. Always use + // the dataSpec length in this case. + bytesToRead = dataSpecParameter.length; + } + + try { + inputStream = httpURLConnection.getInputStream(); + if (isCompressed) { + inputStream = new GZIPInputStream(inputStream); + } + } catch (final IOException e) { + closeConnectionQuietly(); + throw new HttpDataSourceException(e, dataSpec, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN); + } + + opened = true; + transferStarted(dataSpecParameter); + + try { + skipFully(bytesToSkip, dataSpec); + } catch (final IOException e) { + closeConnectionQuietly(); + if (e instanceof HttpDataSourceException) { + throw (HttpDataSourceException) e; + } + throw new HttpDataSourceException(e, dataSpec, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN); + } + + return bytesToRead; + } + + @Override + public int read(@NonNull final byte[] buffer, final int offset, final int length) + throws HttpDataSourceException { + try { + return readInternal(buffer, offset, length); + } catch (final IOException e) { + throw HttpDataSourceException.createForIOException(e, castNonNull(dataSpec), + HttpDataSourceException.TYPE_READ); + } + } + + @Override + public void close() throws HttpDataSourceException { + try { + final InputStream connectionInputStream = this.inputStream; + if (connectionInputStream != null) { + final long bytesRemaining = bytesToRead == C.LENGTH_UNSET + ? C.LENGTH_UNSET + : bytesToRead - bytesRead; + maybeTerminateInputStream(connection, bytesRemaining); + + try { + connectionInputStream.close(); + } catch (final IOException e) { + throw new HttpDataSourceException(e, castNonNull(dataSpec), + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_CLOSE); + } + } + } finally { + inputStream = null; + closeConnectionQuietly(); + if (opened) { + opened = false; + transferEnded(); + } + } + } + + @NonNull + private HttpURLConnection makeConnection(@NonNull final DataSpec dataSpecToUse) + throws IOException { + URL url = new URL(dataSpecToUse.uri.toString()); + @HttpMethod int httpMethod = dataSpecToUse.httpMethod; + @Nullable byte[] httpBody = dataSpecToUse.httpBody; + final long position = dataSpecToUse.position; + final long length = dataSpecToUse.length; + final boolean allowGzip = dataSpecToUse.isFlagSet(DataSpec.FLAG_ALLOW_GZIP); + + if (!allowCrossProtocolRedirects && !keepPostFor302Redirects) { + // HttpURLConnection disallows cross-protocol redirects, but otherwise performs + // redirection automatically. This is the behavior we want, so use it. + return makeConnection(url, httpMethod, httpBody, position, length, allowGzip, true, + dataSpecToUse.httpRequestHeaders); + } + + // We need to handle redirects ourselves to allow cross-protocol redirects or to keep the + // POST request method for 302. + int redirectCount = 0; + while (redirectCount++ <= MAX_REDIRECTS) { + final HttpURLConnection httpURLConnection = makeConnection(url, httpMethod, httpBody, + position, length, allowGzip, false, dataSpecToUse.httpRequestHeaders); + final int httpURLConnectionResponseCode = httpURLConnection.getResponseCode(); + final String location = httpURLConnection.getHeaderField("Location"); + if ((httpMethod == DataSpec.HTTP_METHOD_GET || httpMethod == DataSpec.HTTP_METHOD_HEAD) + && (httpURLConnectionResponseCode == HttpURLConnection.HTTP_MULT_CHOICE + || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_PERM + || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_TEMP + || httpURLConnectionResponseCode == HttpURLConnection.HTTP_SEE_OTHER + || httpURLConnectionResponseCode == HTTP_STATUS_TEMPORARY_REDIRECT + || httpURLConnectionResponseCode == HTTP_STATUS_PERMANENT_REDIRECT)) { + httpURLConnection.disconnect(); + url = handleRedirect(url, location, dataSpecToUse); + } else if (httpMethod == DataSpec.HTTP_METHOD_POST + && (httpURLConnectionResponseCode == HttpURLConnection.HTTP_MULT_CHOICE + || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_PERM + || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_TEMP + || httpURLConnectionResponseCode == HttpURLConnection.HTTP_SEE_OTHER)) { + httpURLConnection.disconnect(); + final boolean shouldKeepPost = keepPostFor302Redirects + && responseCode == HttpURLConnection.HTTP_MOVED_TEMP; + if (!shouldKeepPost) { + // POST request follows the redirect and is transformed into a GET request. + httpMethod = DataSpec.HTTP_METHOD_GET; + httpBody = null; + } + url = handleRedirect(url, location, dataSpecToUse); + } else { + return httpURLConnection; + } + } + + // If we get here we've been redirected more times than are permitted. + throw new HttpDataSourceException( + new NoRouteToHostException("Too many redirects: " + redirectCount), + dataSpecToUse, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + + /** + * Configures a connection and opens it. + * + * @param url The url to connect to. + * @param httpMethod The http method. + * @param httpBody The body data, or {@code null} if not required. + * @param position The byte offset of the requested data. + * @param length The length of the requested data, or {@link C#LENGTH_UNSET}. + * @param allowGzip Whether to allow the use of gzip. + * @param followRedirects Whether to follow redirects. + * @param requestParameters parameters (HTTP headers) to include in request. + * @return the connection opened + */ + @SuppressWarnings("checkstyle:ParameterNumber") + @NonNull + private HttpURLConnection makeConnection( + @NonNull final URL url, + @HttpMethod final int httpMethod, + @Nullable final byte[] httpBody, + final long position, + final long length, + final boolean allowGzip, + final boolean followRedirects, + final Map requestParameters) throws IOException { + // This is the method that contains breaking changes with respect to DefaultHttpDataSource! + + String requestUrl = url.toString(); + + // Don't add the request number parameter if it has been already added (for instance in + // DASH manifests) or if that's not a videoplayback URL + final boolean isVideoPlaybackUrl = url.getPath().startsWith("/videoplayback"); + if (isVideoPlaybackUrl && rnParameterEnabled && !requestUrl.contains(RN_PARAMETER)) { + requestUrl += RN_PARAMETER + requestNumber; + ++requestNumber; + } + + if (rangeParameterEnabled && isVideoPlaybackUrl) { + final String rangeParameterBuilt = buildRangeParameter(position, length); + if (rangeParameterBuilt != null) { + requestUrl += rangeParameterBuilt; + } + } + + final HttpURLConnection httpURLConnection = openConnection(new URL(requestUrl)); + httpURLConnection.setConnectTimeout(connectTimeoutMillis); + httpURLConnection.setReadTimeout(readTimeoutMillis); + + final Map requestHeaders = new HashMap<>(); + if (defaultRequestProperties != null) { + requestHeaders.putAll(defaultRequestProperties.getSnapshot()); + } + requestHeaders.putAll(requestProperties.getSnapshot()); + requestHeaders.putAll(requestParameters); + + for (final Map.Entry property : requestHeaders.entrySet()) { + httpURLConnection.setRequestProperty(property.getKey(), property.getValue()); + } + + if (!rangeParameterEnabled) { + final String rangeHeader = buildRangeRequestHeader(position, length); + if (rangeHeader != null) { + httpURLConnection.setRequestProperty(HttpHeaders.RANGE, rangeHeader); + } + } + + if (isWebStreamingUrl(requestUrl) + || isTvHtml5SimplyEmbeddedPlayerStreamingUrl(requestUrl)) { + httpURLConnection.setRequestProperty(HttpHeaders.ORIGIN, YOUTUBE_BASE_URL); + httpURLConnection.setRequestProperty(HttpHeaders.REFERER, YOUTUBE_BASE_URL); + httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_DEST, "empty"); + httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_MODE, "cors"); + httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_SITE, "cross-site"); + } + + httpURLConnection.setRequestProperty(HttpHeaders.TE, "trailers"); + + final boolean isAndroidStreamingUrl = isAndroidStreamingUrl(requestUrl); + final boolean isIosStreamingUrl = isIosStreamingUrl(requestUrl); + if (isAndroidStreamingUrl) { + // Improvement which may be done: find the content country used to request YouTube + // contents to add it in the user agent instead of using the default + httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, + getAndroidUserAgent(null)); + } else if (isIosStreamingUrl) { + httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, + getIosUserAgent(null)); + } else { + // non-mobile user agent + httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, DownloaderImpl.USER_AGENT); + } + + httpURLConnection.setRequestProperty(HttpHeaders.ACCEPT_ENCODING, + allowGzip ? "gzip" : "identity"); + httpURLConnection.setInstanceFollowRedirects(followRedirects); + httpURLConnection.setDoOutput(httpBody != null); + + // Mobile clients uses POST requests to fetch contents + httpURLConnection.setRequestMethod(isAndroidStreamingUrl || isIosStreamingUrl + ? "POST" + : DataSpec.getStringForHttpMethod(httpMethod)); + + if (httpBody != null) { + httpURLConnection.setFixedLengthStreamingMode(httpBody.length); + httpURLConnection.connect(); + final OutputStream os = httpURLConnection.getOutputStream(); + os.write(httpBody); + os.close(); + } else { + httpURLConnection.connect(); + } + return httpURLConnection; + } + + /** + * Creates an {@link HttpURLConnection} that is connected with the {@code url}. + * + * @param url the {@link URL} to create an {@link HttpURLConnection} + * @return an {@link HttpURLConnection} created with the {@code url} + */ + private HttpURLConnection openConnection(@NonNull final URL url) throws IOException { + return (HttpURLConnection) url.openConnection(); + } + + /** + * Handles a redirect. + * + * @param originalUrl The original URL. + * @param location The Location header in the response. May be {@code null}. + * @param dataSpecToHandleRedirect The {@link DataSpec}. + * @return The next URL. + * @throws HttpDataSourceException If redirection isn't possible. + */ + @NonNull + private URL handleRedirect(final URL originalUrl, + @Nullable final String location, + final DataSpec dataSpecToHandleRedirect) + throws HttpDataSourceException { + if (location == null) { + throw new HttpDataSourceException("Null location redirect", dataSpecToHandleRedirect, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + + // Form the new url. + final URL url; + try { + url = new URL(originalUrl, location); + } catch (final MalformedURLException e) { + throw new HttpDataSourceException(e, dataSpecToHandleRedirect, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + + // Check that the protocol of the new url is supported. + final String protocol = url.getProtocol(); + if (!"https".equals(protocol) && !"http".equals(protocol)) { + throw new HttpDataSourceException("Unsupported protocol redirect: " + protocol, + dataSpecToHandleRedirect, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + + if (!allowCrossProtocolRedirects && !protocol.equals(originalUrl.getProtocol())) { + throw new HttpDataSourceException( + "Disallowed cross-protocol redirect (" + + originalUrl.getProtocol() + + " to " + + protocol + + ")", + dataSpecToHandleRedirect, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + + return url; + } + + /** + * Attempts to skip the specified number of bytes in full. + * + * @param bytesToSkip The number of bytes to skip. + * @param dataSpecToUse The {@link DataSpec}. + * @throws IOException If the thread is interrupted during the operation, or if the data ended + * before skipping the specified number of bytes. + */ + @SuppressWarnings("checkstyle:FinalParameters") + private void skipFully(long bytesToSkip, final DataSpec dataSpecToUse) throws IOException { + if (bytesToSkip == 0) { + return; + } + + final byte[] skipBuffer = new byte[4096]; + while (bytesToSkip > 0) { + final int readLength = (int) min(bytesToSkip, skipBuffer.length); + final int read = castNonNull(inputStream).read(skipBuffer, 0, readLength); + if (Thread.currentThread().isInterrupted()) { + throw new HttpDataSourceException( + new InterruptedIOException(), + dataSpecToUse, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN); + } + + if (read == -1) { + throw new HttpDataSourceException( + dataSpecToUse, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, + HttpDataSourceException.TYPE_OPEN); + } + + bytesToSkip -= read; + bytesTransferred(read); + } + } + + /** + * Reads up to {@code length} bytes of data and stores them into {@code buffer}, starting at + * index {@code offset}. + * + *

+ * This method blocks until at least one byte of data can be read, the end of the opened range + * is detected, or an exception is thrown. + *

+ * + * @param buffer The buffer into which the read data should be stored. + * @param offset The start offset into {@code buffer} at which data should be written. + * @param readLength The maximum number of bytes to read. + * @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if the end of the opened + * range is reached. + * @throws IOException If an error occurs reading from the source. + */ + @SuppressWarnings("checkstyle:FinalParameters") + private int readInternal(final byte[] buffer, final int offset, int readLength) + throws IOException { + if (readLength == 0) { + return 0; + } + if (bytesToRead != C.LENGTH_UNSET) { + final long bytesRemaining = bytesToRead - bytesRead; + if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } + readLength = (int) min(readLength, bytesRemaining); + } + + final int read = castNonNull(inputStream).read(buffer, offset, readLength); + if (read == -1) { + return C.RESULT_END_OF_INPUT; + } + + bytesRead += read; + bytesTransferred(read); + return read; + } + + /** + * On platform API levels 19 and 20, okhttp's implementation of {@link InputStream#close} can + * block for a long time if the stream has a lot of data remaining. Call this method before + * closing the input stream to make a best effort to cause the input stream to encounter an + * unexpected end of input, working around this issue. On other platform API levels, the method + * does nothing. + * + * @param connection The connection whose {@link InputStream} should be terminated. + * @param bytesRemaining The number of bytes remaining to be read from the input stream if its + * length is known. {@link C#LENGTH_UNSET} otherwise. + */ + private static void maybeTerminateInputStream(@Nullable final HttpURLConnection connection, + final long bytesRemaining) { + if (connection == null || Util.SDK_INT < 19 || Util.SDK_INT > 20) { + return; + } + + try { + final InputStream inputStream = connection.getInputStream(); + if (bytesRemaining == C.LENGTH_UNSET) { + // If the input stream has already ended, do nothing. The socket may be re-used. + if (inputStream.read() == -1) { + return; + } + } else if (bytesRemaining <= MAX_BYTES_TO_DRAIN) { + // There isn't much data left. Prefer to allow it to drain, which may allow the + // socket to be re-used. + return; + } + final String className = inputStream.getClass().getName(); + if ("com.android.okhttp.internal.http.HttpTransport$ChunkedInputStream" + .equals(className) + || "com.android.okhttp.internal.http.HttpTransport$FixedLengthInputStream" + .equals(className)) { + final Class superclass = inputStream.getClass().getSuperclass(); + final Method unexpectedEndOfInput = checkNotNull(superclass).getDeclaredMethod( + "unexpectedEndOfInput"); + unexpectedEndOfInput.setAccessible(true); + unexpectedEndOfInput.invoke(inputStream); + } + } catch (final Exception e) { + // If an IOException then the connection didn't ever have an input stream, or it was + // closed already. If another type of exception then something went wrong, most likely + // the device isn't using okhttp. + } + } + + /** + * Closes the current connection quietly, if there is one. + */ + private void closeConnectionQuietly() { + if (connection != null) { + try { + connection.disconnect(); + } catch (final Exception e) { + Log.e(TAG, "Unexpected error while disconnecting", e); + } + connection = null; + } + } + + private static boolean isCompressed(@NonNull final HttpURLConnection connection) { + final String contentEncoding = connection.getHeaderField("Content-Encoding"); + return "gzip".equalsIgnoreCase(contentEncoding); + } + + /** + * Builds a {@code range} parameter for the given position and length. + * + *

+ * To fetch its contents, YouTube use range requests which append a {@code range} parameter + * to videoplayback URLs instead of the {@code Range} header (even if the server respond + * correctly when requesting a range of a ressouce with it). + *

+ * + *

+ * The parameter works in the same way as the header. + *

+ * + * @param position The request position. + * @param length The request length, or {@link C#LENGTH_UNSET} if the request is unbounded. + * @return The corresponding {@code range} parameter, or {@code null} if this parameter is + * unnecessary because the whole resource is being requested. + */ + @Nullable + private static String buildRangeParameter(final long position, final long length) { + if (position == 0 && length == C.LENGTH_UNSET) { + return null; + } + + final StringBuilder rangeParameter = new StringBuilder(); + rangeParameter.append("&range="); + rangeParameter.append(position); + rangeParameter.append("-"); + if (length != C.LENGTH_UNSET) { + rangeParameter.append(position + length - 1); + } + return rangeParameter.toString(); + } + + private static final class NullFilteringHeadersMap + extends ForwardingMap> { + private final Map> headers; + + NullFilteringHeadersMap(final Map> headers) { + this.headers = headers; + } + + @NonNull + @Override + protected Map> delegate() { + return headers; + } + + @Override + public boolean containsKey(@Nullable final Object key) { + return key != null && super.containsKey(key); + } + + @Nullable + @Override + public List get(@Nullable final Object key) { + return key == null ? null : super.get(key); + } + + @NonNull + @Override + public Set keySet() { + return Sets.filter(super.keySet(), Objects::nonNull); + } + + @NonNull + @Override + public Set>> entrySet() { + return Sets.filter(super.entrySet(), entry -> entry.getKey() != null); + } + + @Override + public int size() { + return super.size() - (super.containsKey(null) ? 1 : 0); + } + + @Override + public boolean isEmpty() { + return super.isEmpty() || (super.size() == 1 && super.containsKey(null)); + } + + @Override + public boolean containsValue(@Nullable final Object value) { + return super.standardContainsValue(value); + } + + @Override + public boolean equals(@Nullable final Object object) { + return object != null && super.standardEquals(object); + } + + @Override + public int hashCode() { + return super.standardHashCode(); + } + } +} + diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerGestureListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerGestureListener.java index 794fe9b3c..a7fb40c47 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerGestureListener.java +++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerGestureListener.java @@ -126,6 +126,14 @@ public class PlayerGestureListener } private void onScrollMainVolume(final float distanceX, final float distanceY) { + // If we just started sliding, change the progress bar to match the system volume + if (player.getVolumeRelativeLayout().getVisibility() != View.VISIBLE) { + final float volumePercent = player + .getAudioReactor().getVolume() / (float) maxVolume; + player.getVolumeProgressBar().setProgress( + (int) (volumePercent * player.getMaxGestureLength())); + } + player.getVolumeProgressBar().incrementProgressBy((int) distanceY); final float currentProgressPercent = (float) player .getVolumeProgressBar().getProgress() / player.getMaxGestureLength(); 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..d189616d1 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 @@ -1,93 +1,46 @@ 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.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSource; -import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.upstream.FileDataSource; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.upstream.cache.CacheDataSink; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; -import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; -import java.io.File; +final class CacheFactory implements DataSource.Factory { + private static final int CACHE_FLAGS = CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; -/* package-private */ class CacheFactory implements DataSource.Factory { - private static final String TAG = "CacheFactory"; + private final Context context; + private final TransferListener transferListener; + private final DataSource.Factory upstreamDataSourceFactory; + private final SimpleCache cache; - private static final String CACHE_FOLDER_NAME = "exoplayer"; - private static final int CACHE_FLAGS = CacheDataSource.FLAG_BLOCK_ON_CACHE - | CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; - - private final DefaultDataSourceFactory dataSourceFactory; - private final File cacheDir; - private final long maxFileSize; - - // Creating cache on every instance may cause problems with multiple players when - // sources are not ExtractorMediaSource - // see: https://stackoverflow.com/questions/28700391/using-cache-in-exoplayer - // todo: make this a singleton? - private static SimpleCache cache; - - CacheFactory(@NonNull final Context context, - @NonNull final String userAgent, - @NonNull final TransferListener transferListener) { - this(context, userAgent, transferListener, PlayerHelper.getPreferredCacheSize(), - PlayerHelper.getPreferredFileSize()); - } - - private CacheFactory(@NonNull final Context context, - @NonNull final String userAgent, - @NonNull final TransferListener transferListener, - final long maxCacheSize, - final long maxFileSize) { - this.maxFileSize = maxFileSize; - - dataSourceFactory = new DefaultDataSourceFactory(context, userAgent, transferListener); - cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); - if (!cacheDir.exists()) { - //noinspection ResultOfMethodCallIgnored - cacheDir.mkdir(); - } - - if (cache == null) { - final LeastRecentlyUsedCacheEvictor evictor - = new LeastRecentlyUsedCacheEvictor(maxCacheSize); - cache = new SimpleCache(cacheDir, evictor, new ExoDatabaseProvider(context)); - } + CacheFactory(final Context context, + final TransferListener transferListener, + final SimpleCache cache, + final DataSource.Factory upstreamDataSourceFactory) { + this.context = context; + this.transferListener = transferListener; + this.cache = cache; + this.upstreamDataSourceFactory = upstreamDataSourceFactory; } + @NonNull @Override public DataSource createDataSource() { - Log.d(TAG, "initExoPlayerCache: cacheDir = " + cacheDir.getAbsolutePath()); + final DefaultDataSource dataSource = new DefaultDataSource.Factory(context, + upstreamDataSourceFactory) + .setTransferListener(transferListener) + .createDataSource(); - final DefaultDataSource dataSource = dataSourceFactory.createDataSource(); final FileDataSource fileSource = new FileDataSource(); - final CacheDataSink dataSink = new CacheDataSink(cache, maxFileSize); - + final CacheDataSink dataSink + = new CacheDataSink(cache, PlayerHelper.getPreferredFileSize()); return new CacheDataSource(cache, dataSource, fileSource, dataSink, CACHE_FLAGS, null); } - - public void tryDeleteCacheFiles() { - if (!cacheDir.exists() || !cacheDir.isDirectory()) { - return; - } - - try { - for (final File file : cacheDir.listFiles()) { - final String filePath = file.getAbsolutePath(); - final boolean deleteSuccessful = file.delete(); - - Log.d(TAG, "tryDeleteCacheFiles: " + filePath + " deleted = " + deleteSuccessful); - } - } catch (final Exception ignored) { - Log.e(TAG, "Failed to delete file.", ignored); - } - } } 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..a8735dc08 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 @@ -13,13 +13,13 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.media.session.MediaButtonReceiver; +import com.google.android.exoplayer2.ForwardingPlayer; import com.google.android.exoplayer2.Player; 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,9 +55,18 @@ public class MediaSessionManager { .build()); sessionConnector = new MediaSessionConnector(mediaSession); - sessionConnector.setControlDispatcher(new PlayQueuePlaybackController(callback)); sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, callback)); - sessionConnector.setPlayer(player); + sessionConnector.setPlayer(new ForwardingPlayer(player) { + @Override + public void play() { + callback.play(); + } + + @Override + public void pause() { + callback.pause(); + } + }); } @Nullable @@ -135,9 +144,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..19a5a645b 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 @@ -1,12 +1,17 @@ package org.schabi.newpipe.player.helper; +import static org.schabi.newpipe.ktx.ViewUtils.animateRotation; import static org.schabi.newpipe.player.Player.DEBUG; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; +import static org.schabi.newpipe.util.ThemeHelper.resolveDrawable; import android.app.Dialog; import android.content.Context; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; import android.os.Bundle; import android.util.Log; +import android.view.LayoutInflater; import android.view.View; import android.widget.CheckBox; import android.widget.SeekBar; @@ -14,95 +19,103 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.DialogFragment; import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.DialogPlaybackParameterBinding; +import org.schabi.newpipe.player.Player; +import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; import org.schabi.newpipe.util.SliderStrategy; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.DoubleConsumer; +import java.util.function.DoubleFunction; +import java.util.function.DoubleSupplier; + +import icepick.Icepick; +import icepick.State; + public class PlaybackParameterDialog extends DialogFragment { + private static final String TAG = "PlaybackParameterDialog"; + // Minimum allowable range in ExoPlayer - private static final double MINIMUM_PLAYBACK_VALUE = 0.10f; - private static final double MAXIMUM_PLAYBACK_VALUE = 3.00f; + private static final double MIN_PITCH_OR_SPEED = 0.10f; + private static final double MAX_PITCH_OR_SPEED = 3.00f; - private static final char STEP_UP_SIGN = '+'; - private static final char STEP_DOWN_SIGN = '-'; + private static final boolean PITCH_CTRL_MODE_PERCENT = false; + private static final boolean PITCH_CTRL_MODE_SEMITONE = true; - private static final double STEP_ONE_PERCENT_VALUE = 0.01f; - private static final double STEP_FIVE_PERCENT_VALUE = 0.05f; - private static final double STEP_TEN_PERCENT_VALUE = 0.10f; - private static final double STEP_TWENTY_FIVE_PERCENT_VALUE = 0.25f; - private static final double STEP_ONE_HUNDRED_PERCENT_VALUE = 1.00f; + private static final double STEP_1_PERCENT_VALUE = 0.01f; + private static final double STEP_5_PERCENT_VALUE = 0.05f; + private static final double STEP_10_PERCENT_VALUE = 0.10f; + private static final double STEP_25_PERCENT_VALUE = 0.25f; + private static final double STEP_100_PERCENT_VALUE = 1.00f; private static final double DEFAULT_TEMPO = 1.00f; - private static final double DEFAULT_PITCH = 1.00f; - private static final double DEFAULT_STEP = STEP_TWENTY_FIVE_PERCENT_VALUE; + private static final double DEFAULT_PITCH_PERCENT = 1.00f; + private static final double DEFAULT_STEP = STEP_25_PERCENT_VALUE; private static final boolean DEFAULT_SKIP_SILENCE = false; - @NonNull - private static final String TAG = "PlaybackParameterDialog"; - @NonNull - private static final String INITIAL_TEMPO_KEY = "initial_tempo_key"; - @NonNull - private static final String INITIAL_PITCH_KEY = "initial_pitch_key"; + private static final SliderStrategy QUADRATIC_STRATEGY = new SliderStrategy.Quadratic( + MIN_PITCH_OR_SPEED, + MAX_PITCH_OR_SPEED, + 1.00f, + 10_000); - @NonNull - private static final String TEMPO_KEY = "tempo_key"; - @NonNull - private static final String PITCH_KEY = "pitch_key"; - @NonNull - private static final String STEP_SIZE_KEY = "step_size_key"; + private static final SliderStrategy SEMITONE_STRATEGY = new SliderStrategy() { + @Override + public int progressOf(final double value) { + return PlayerSemitoneHelper.percentToSemitones(value) + 12; + } - @NonNull - private final SliderStrategy strategy = new SliderStrategy.Quadratic( - MINIMUM_PLAYBACK_VALUE, MAXIMUM_PLAYBACK_VALUE, - /*centerAt=*/1.00f, /*sliderGranularity=*/10000); + @Override + public double valueOf(final int progress) { + return PlayerSemitoneHelper.semitonesToPercent(progress - 12); + } + }; @Nullable private Callback callback; - private double initialTempo = DEFAULT_TEMPO; - private double initialPitch = DEFAULT_PITCH; - private boolean initialSkipSilence = DEFAULT_SKIP_SILENCE; - private double tempo = DEFAULT_TEMPO; - private double pitch = DEFAULT_PITCH; - private double stepSize = DEFAULT_STEP; + @State + double initialTempo = DEFAULT_TEMPO; + @State + double initialPitchPercent = DEFAULT_PITCH_PERCENT; + @State + boolean initialSkipSilence = DEFAULT_SKIP_SILENCE; - @Nullable - private SeekBar tempoSlider; - @Nullable - private TextView tempoCurrentText; - @Nullable - private TextView tempoStepDownText; - @Nullable - private TextView tempoStepUpText; - @Nullable - private SeekBar pitchSlider; - @Nullable - private TextView pitchCurrentText; - @Nullable - private TextView pitchStepDownText; - @Nullable - private TextView pitchStepUpText; - @Nullable - private CheckBox unhookingCheckbox; - @Nullable - private CheckBox skipSilenceCheckbox; + @State + double tempo = DEFAULT_TEMPO; + @State + double pitchPercent = DEFAULT_PITCH_PERCENT; + @State + boolean skipSilence = DEFAULT_SKIP_SILENCE; - public static PlaybackParameterDialog newInstance(final double playbackTempo, - final double playbackPitch, - final boolean playbackSkipSilence, - final Callback callback) { + private DialogPlaybackParameterBinding binding; + + public static PlaybackParameterDialog newInstance( + final double playbackTempo, + final double playbackPitch, + final boolean playbackSkipSilence, + final Callback callback + ) { final PlaybackParameterDialog dialog = new PlaybackParameterDialog(); dialog.callback = callback; + dialog.initialTempo = playbackTempo; - dialog.initialPitch = playbackPitch; - - dialog.tempo = playbackTempo; - dialog.pitch = playbackPitch; - + dialog.initialPitchPercent = playbackPitch; dialog.initialSkipSilence = playbackSkipSilence; + + dialog.tempo = dialog.initialTempo; + dialog.pitchPercent = dialog.initialPitchPercent; + dialog.skipSilence = dialog.initialSkipSilence; + return dialog; } @@ -111,7 +124,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; @@ -121,28 +134,9 @@ public class PlaybackParameterDialog extends DialogFragment { } @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - assureCorrectAppLanguage(getContext()); - super.onCreate(savedInstanceState); - if (savedInstanceState != null) { - initialTempo = savedInstanceState.getDouble(INITIAL_TEMPO_KEY, DEFAULT_TEMPO); - initialPitch = savedInstanceState.getDouble(INITIAL_PITCH_KEY, DEFAULT_PITCH); - - tempo = savedInstanceState.getDouble(TEMPO_KEY, DEFAULT_TEMPO); - pitch = savedInstanceState.getDouble(PITCH_KEY, DEFAULT_PITCH); - stepSize = savedInstanceState.getDouble(STEP_SIZE_KEY, DEFAULT_STEP); - } - } - - @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()); + Icepick.saveInstanceState(this, outState); } /*////////////////////////////////////////////////////////////////////////// @@ -153,196 +147,345 @@ public class PlaybackParameterDialog extends DialogFragment { @Override public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { assureCorrectAppLanguage(getContext()); - final View view = View.inflate(getContext(), R.layout.dialog_playback_parameter, null); - setupControlViews(view); + Icepick.restoreInstanceState(this, savedInstanceState); + + binding = DialogPlaybackParameterBinding.inflate(LayoutInflater.from(getContext())); + initUI(); final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(requireActivity()) - .setView(view) + .setView(binding.getRoot()) .setCancelable(true) - .setNegativeButton(R.string.cancel, (dialogInterface, i) -> - setPlaybackParameters(initialTempo, initialPitch, initialSkipSilence)) - .setNeutralButton(R.string.playback_reset, (dialogInterface, i) -> - setPlaybackParameters(DEFAULT_TEMPO, DEFAULT_PITCH, DEFAULT_SKIP_SILENCE)) - .setPositiveButton(R.string.ok, (dialogInterface, i) -> - setCurrentPlaybackParameters()); + .setNegativeButton(R.string.cancel, (dialogInterface, i) -> { + setAndUpdateTempo(initialTempo); + setAndUpdatePitch(initialPitchPercent); + setAndUpdateSkipSilence(initialSkipSilence); + updateCallback(); + }) + .setNeutralButton(R.string.playback_reset, (dialogInterface, i) -> { + setAndUpdateTempo(DEFAULT_TEMPO); + setAndUpdatePitch(DEFAULT_PITCH_PERCENT); + setAndUpdateSkipSilence(DEFAULT_SKIP_SILENCE); + updateCallback(); + }) + .setPositiveButton(R.string.ok, (dialogInterface, i) -> updateCallback()); return dialogBuilder.create(); } /*////////////////////////////////////////////////////////////////////////// - // Control Views + // UI Initialization and Control //////////////////////////////////////////////////////////////////////////*/ - private void setupControlViews(@NonNull final View rootView) { - setupHookingControl(rootView); - setupSkipSilenceControl(rootView); + private void initUI() { + // Tempo + setText(binding.tempoMinimumText, PlayerHelper::formatSpeed, MIN_PITCH_OR_SPEED); + setText(binding.tempoMaximumText, PlayerHelper::formatSpeed, MAX_PITCH_OR_SPEED); - setupTempoControl(rootView); - setupPitchControl(rootView); + binding.tempoSeekbar.setMax(QUADRATIC_STRATEGY.progressOf(MAX_PITCH_OR_SPEED)); + setAndUpdateTempo(tempo); + binding.tempoSeekbar.setOnSeekBarChangeListener( + getTempoOrPitchSeekbarChangeListener( + QUADRATIC_STRATEGY, + this::onTempoSliderUpdated)); - setStepSize(stepSize); - setupStepSizeSelector(rootView); + registerOnStepClickListener( + binding.tempoStepDown, + () -> tempo, + -1, + this::onTempoSliderUpdated); + registerOnStepClickListener( + binding.tempoStepUp, + () -> tempo, + 1, + this::onTempoSliderUpdated); + + // Pitch + binding.pitchToogleControlModes.setOnClickListener(v -> { + final boolean isCurrentlyVisible = + binding.pitchControlModeTabs.getVisibility() == View.GONE; + binding.pitchControlModeTabs.setVisibility(isCurrentlyVisible + ? View.VISIBLE + : View.GONE); + animateRotation(binding.pitchToogleControlModes, + Player.DEFAULT_CONTROLS_DURATION, + isCurrentlyVisible ? 180 : 0); + }); + + getPitchControlModeComponentMappings() + .forEach(this::setupPitchControlModeTextView); + // Initialization is done at the end + + // Pitch - Percent + setText(binding.pitchPercentMinimumText, PlayerHelper::formatPitch, MIN_PITCH_OR_SPEED); + setText(binding.pitchPercentMaximumText, PlayerHelper::formatPitch, MAX_PITCH_OR_SPEED); + + binding.pitchPercentSeekbar.setMax(QUADRATIC_STRATEGY.progressOf(MAX_PITCH_OR_SPEED)); + setAndUpdatePitch(pitchPercent); + binding.pitchPercentSeekbar.setOnSeekBarChangeListener( + getTempoOrPitchSeekbarChangeListener( + QUADRATIC_STRATEGY, + this::onPitchPercentSliderUpdated)); + + registerOnStepClickListener( + binding.pitchPercentStepDown, + () -> pitchPercent, + -1, + this::onPitchPercentSliderUpdated); + registerOnStepClickListener( + binding.pitchPercentStepUp, + () -> pitchPercent, + 1, + this::onPitchPercentSliderUpdated); + + // Pitch - Semitone + binding.pitchSemitoneSeekbar.setOnSeekBarChangeListener( + getTempoOrPitchSeekbarChangeListener( + SEMITONE_STRATEGY, + this::onPitchPercentSliderUpdated)); + + registerOnSemitoneStepClickListener( + binding.pitchSemitoneStepDown, + -1, + this::onPitchPercentSliderUpdated); + registerOnSemitoneStepClickListener( + binding.pitchSemitoneStepUp, + 1, + this::onPitchPercentSliderUpdated); + + // Steps + getStepSizeComponentMappings() + .forEach(this::setupStepTextView); + // Initialize UI + setStepSizeToUI(getCurrentStepSize()); + + // Bottom controls + bindCheckboxWithBoolPref( + binding.unhookCheckbox, + R.string.playback_unhook_key, + true, + isChecked -> { + if (!isChecked) { + // when unchecked, slide back to the minimum of current tempo or pitch + ensureHookIsValidAndUpdateCallBack(); + } + }); + + setAndUpdateSkipSilence(skipSilence); + binding.skipSilenceCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> { + skipSilence = isChecked; + updateCallback(); + }); + + // PitchControlMode has to be initialized at the end because it requires the unhookCheckbox + changePitchControlMode(isCurrentPitchControlModeSemitone()); } - private void setupTempoControl(@NonNull final View rootView) { - tempoSlider = rootView.findViewById(R.id.tempoSeekbar); - final TextView tempoMinimumText = rootView.findViewById(R.id.tempoMinimumText); - final TextView tempoMaximumText = rootView.findViewById(R.id.tempoMaximumText); - tempoCurrentText = rootView.findViewById(R.id.tempoCurrentText); - tempoStepUpText = rootView.findViewById(R.id.tempoStepUp); - tempoStepDownText = rootView.findViewById(R.id.tempoStepDown); + // -- General formatting -- - if (tempoCurrentText != null) { - tempoCurrentText.setText(PlayerHelper.formatSpeed(tempo)); - } - if (tempoMaximumText != null) { - tempoMaximumText.setText(PlayerHelper.formatSpeed(MAXIMUM_PLAYBACK_VALUE)); - } - if (tempoMinimumText != null) { - tempoMinimumText.setText(PlayerHelper.formatSpeed(MINIMUM_PLAYBACK_VALUE)); - } - - if (tempoSlider != null) { - tempoSlider.setMax(strategy.progressOf(MAXIMUM_PLAYBACK_VALUE)); - tempoSlider.setProgress(strategy.progressOf(tempo)); - tempoSlider.setOnSeekBarChangeListener(getOnTempoChangedListener()); - } + private void setText( + final TextView textView, + final DoubleFunction formatter, + final double value + ) { + Objects.requireNonNull(textView).setText(formatter.apply(value)); } - private void setupPitchControl(@NonNull final View rootView) { - pitchSlider = rootView.findViewById(R.id.pitchSeekbar); - final TextView pitchMinimumText = rootView.findViewById(R.id.pitchMinimumText); - final TextView pitchMaximumText = rootView.findViewById(R.id.pitchMaximumText); - pitchCurrentText = rootView.findViewById(R.id.pitchCurrentText); - pitchStepDownText = rootView.findViewById(R.id.pitchStepDown); - pitchStepUpText = rootView.findViewById(R.id.pitchStepUp); + // -- Steps -- - if (pitchCurrentText != null) { - pitchCurrentText.setText(PlayerHelper.formatPitch(pitch)); - } - if (pitchMaximumText != null) { - pitchMaximumText.setText(PlayerHelper.formatPitch(MAXIMUM_PLAYBACK_VALUE)); - } - if (pitchMinimumText != null) { - pitchMinimumText.setText(PlayerHelper.formatPitch(MINIMUM_PLAYBACK_VALUE)); - } - - if (pitchSlider != null) { - pitchSlider.setMax(strategy.progressOf(MAXIMUM_PLAYBACK_VALUE)); - pitchSlider.setProgress(strategy.progressOf(pitch)); - pitchSlider.setOnSeekBarChangeListener(getOnPitchChangedListener()); - } + private void registerOnStepClickListener( + final TextView stepTextView, + final DoubleSupplier currentValueSupplier, + final double direction, // -1 for step down, +1 for step up + final DoubleConsumer newValueConsumer + ) { + stepTextView.setOnClickListener(view -> { + newValueConsumer.accept( + currentValueSupplier.getAsDouble() + 1 * getCurrentStepSize() * direction); + updateCallback(); + }); } - 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 - unhookingCheckbox.setChecked(PreferenceManager - .getDefaultSharedPreferences(requireContext()) - .getBoolean(getString(R.string.playback_unhook_key), true)); + private void registerOnSemitoneStepClickListener( + final TextView stepTextView, + final int direction, // -1 for step down, +1 for step up + final DoubleConsumer newValueConsumer + ) { + stepTextView.setOnClickListener(view -> { + newValueConsumer.accept(PlayerSemitoneHelper.semitonesToPercent( + PlayerSemitoneHelper.percentToSemitones(this.pitchPercent) + direction)); + updateCallback(); + }); + } - unhookingCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> { - // save whether pitch and tempo are unhooked or not - PreferenceManager.getDefaultSharedPreferences(requireContext()) - .edit() - .putBoolean(getString(R.string.playback_unhook_key), isChecked) - .apply(); + // -- Pitch -- - if (!isChecked) { - // when unchecked, slide back to the minimum of current tempo or pitch - final double minimum = Math.min(getCurrentPitch(), getCurrentTempo()); - setSliders(minimum); - setCurrentPlaybackParameters(); + private void setupPitchControlModeTextView( + final boolean semitones, + final TextView textView + ) { + textView.setOnClickListener(view -> { + PreferenceManager.getDefaultSharedPreferences(requireContext()) + .edit() + .putBoolean(getString(R.string.playback_adjust_by_semitones_key), semitones) + .apply(); + + changePitchControlMode(semitones); + }); + } + + private Map getPitchControlModeComponentMappings() { + final Map mappings = new HashMap<>(); + mappings.put(PITCH_CTRL_MODE_PERCENT, binding.pitchControlModePercent); + mappings.put(PITCH_CTRL_MODE_SEMITONE, binding.pitchControlModeSemitone); + return mappings; + } + + private void changePitchControlMode(final boolean semitones) { + // Bring all textviews into a normal state + final Map pitchCtrlModeComponentMapping = + getPitchControlModeComponentMappings(); + pitchCtrlModeComponentMapping.forEach((v, textView) -> textView.setBackground( + resolveDrawable(requireContext(), R.attr.selectableItemBackground))); + + // Mark the selected textview + final TextView textView = pitchCtrlModeComponentMapping.get(semitones); + if (textView != null) { + textView.setBackground(new LayerDrawable(new Drawable[]{ + resolveDrawable(requireContext(), R.attr.dashed_border), + resolveDrawable(requireContext(), R.attr.selectableItemBackground) + })); + } + + // Show or hide component + binding.pitchPercentControl.setVisibility(semitones ? View.GONE : View.VISIBLE); + binding.pitchSemitoneControl.setVisibility(semitones ? View.VISIBLE : View.GONE); + + if (semitones) { + // Recalculate pitch percent when changing to semitone + // (as it could be an invalid semitone value) + final double newPitchPercent = calcValidPitch(pitchPercent); + + // If the values differ set the new pitch + if (this.pitchPercent != newPitchPercent) { + if (DEBUG) { + Log.d(TAG, "Bringing pitchPercent to correct corresponding semitone: " + + "currentPitchPercent = " + pitchPercent + ", " + + "newPitchPercent = " + newPitchPercent + ); } - }); + this.onPitchPercentSliderUpdated(newPitchPercent); + updateCallback(); + } + } else if (!binding.unhookCheckbox.isChecked()) { + // When changing to percent it's possible that tempo is != pitch + ensureHookIsValidAndUpdateCallBack(); } } - private void setupSkipSilenceControl(@NonNull final View rootView) { - skipSilenceCheckbox = rootView.findViewById(R.id.skipSilenceCheckbox); - if (skipSilenceCheckbox != null) { - skipSilenceCheckbox.setChecked(initialSkipSilence); - skipSilenceCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> - setCurrentPlaybackParameters()); - } + private boolean isCurrentPitchControlModeSemitone() { + return PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getBoolean( + getString(R.string.playback_adjust_by_semitones_key), + PITCH_CTRL_MODE_PERCENT); } - private void setupStepSizeSelector(@NonNull final View rootView) { - final TextView stepSizeOnePercentText = rootView.findViewById(R.id.stepSizeOnePercent); - final TextView stepSizeFivePercentText = rootView.findViewById(R.id.stepSizeFivePercent); - final TextView stepSizeTenPercentText = rootView.findViewById(R.id.stepSizeTenPercent); - final TextView stepSizeTwentyFivePercentText = rootView - .findViewById(R.id.stepSizeTwentyFivePercent); - final TextView stepSizeOneHundredPercentText = rootView - .findViewById(R.id.stepSizeOneHundredPercent); + // -- Steps (Set) -- - if (stepSizeOnePercentText != null) { - stepSizeOnePercentText.setText(getPercentString(STEP_ONE_PERCENT_VALUE)); - stepSizeOnePercentText - .setOnClickListener(view -> setStepSize(STEP_ONE_PERCENT_VALUE)); - } + private void setupStepTextView( + final double stepSizeValue, + final TextView textView + ) { + setText(textView, PlaybackParameterDialog::getPercentString, stepSizeValue); + textView.setOnClickListener(view -> { + PreferenceManager.getDefaultSharedPreferences(requireContext()) + .edit() + .putFloat(getString(R.string.adjustment_step_key), (float) stepSizeValue) + .apply(); - if (stepSizeFivePercentText != null) { - stepSizeFivePercentText.setText(getPercentString(STEP_FIVE_PERCENT_VALUE)); - stepSizeFivePercentText - .setOnClickListener(view -> setStepSize(STEP_FIVE_PERCENT_VALUE)); - } - - if (stepSizeTenPercentText != null) { - stepSizeTenPercentText.setText(getPercentString(STEP_TEN_PERCENT_VALUE)); - stepSizeTenPercentText - .setOnClickListener(view -> setStepSize(STEP_TEN_PERCENT_VALUE)); - } - - if (stepSizeTwentyFivePercentText != null) { - stepSizeTwentyFivePercentText - .setText(getPercentString(STEP_TWENTY_FIVE_PERCENT_VALUE)); - stepSizeTwentyFivePercentText - .setOnClickListener(view -> setStepSize(STEP_TWENTY_FIVE_PERCENT_VALUE)); - } - - if (stepSizeOneHundredPercentText != null) { - stepSizeOneHundredPercentText - .setText(getPercentString(STEP_ONE_HUNDRED_PERCENT_VALUE)); - stepSizeOneHundredPercentText - .setOnClickListener(view -> setStepSize(STEP_ONE_HUNDRED_PERCENT_VALUE)); - } + setStepSizeToUI(stepSizeValue); + }); } - private void setStepSize(final double stepSize) { - this.stepSize = stepSize; + private Map getStepSizeComponentMappings() { + final Map mappings = new HashMap<>(); + mappings.put(STEP_1_PERCENT_VALUE, binding.stepSizeOnePercent); + mappings.put(STEP_5_PERCENT_VALUE, binding.stepSizeFivePercent); + mappings.put(STEP_10_PERCENT_VALUE, binding.stepSizeTenPercent); + mappings.put(STEP_25_PERCENT_VALUE, binding.stepSizeTwentyFivePercent); + mappings.put(STEP_100_PERCENT_VALUE, binding.stepSizeOneHundredPercent); + return mappings; + } - if (tempoStepUpText != null) { - tempoStepUpText.setText(getStepUpPercentString(stepSize)); - tempoStepUpText.setOnClickListener(view -> { - onTempoSliderUpdated(getCurrentTempo() + stepSize); - setCurrentPlaybackParameters(); - }); + private void setStepSizeToUI(final double newStepSize) { + // Bring all textviews into a normal state + final Map stepSiteComponentMapping = getStepSizeComponentMappings(); + stepSiteComponentMapping.forEach((v, textView) -> textView.setBackground( + resolveDrawable(requireContext(), R.attr.selectableItemBackground))); + + // Mark the selected textview + final TextView textView = stepSiteComponentMapping.get(newStepSize); + if (textView != null) { + textView.setBackground(new LayerDrawable(new Drawable[]{ + resolveDrawable(requireContext(), R.attr.dashed_border), + resolveDrawable(requireContext(), R.attr.selectableItemBackground) + })); } - if (tempoStepDownText != null) { - tempoStepDownText.setText(getStepDownPercentString(stepSize)); - tempoStepDownText.setOnClickListener(view -> { - onTempoSliderUpdated(getCurrentTempo() - stepSize); - setCurrentPlaybackParameters(); - }); - } + // Bind to the corresponding control components + binding.tempoStepUp.setText(getStepUpPercentString(newStepSize)); + binding.tempoStepDown.setText(getStepDownPercentString(newStepSize)); - if (pitchStepUpText != null) { - pitchStepUpText.setText(getStepUpPercentString(stepSize)); - pitchStepUpText.setOnClickListener(view -> { - onPitchSliderUpdated(getCurrentPitch() + stepSize); - setCurrentPlaybackParameters(); - }); - } + binding.pitchPercentStepUp.setText(getStepUpPercentString(newStepSize)); + binding.pitchPercentStepDown.setText(getStepDownPercentString(newStepSize)); + } - if (pitchStepDownText != null) { - pitchStepDownText.setText(getStepDownPercentString(stepSize)); - pitchStepDownText.setOnClickListener(view -> { - onPitchSliderUpdated(getCurrentPitch() - stepSize); - setCurrentPlaybackParameters(); - }); + private double getCurrentStepSize() { + return PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getFloat(getString(R.string.adjustment_step_key), (float) DEFAULT_STEP); + } + + // -- Additional options -- + + private void setAndUpdateSkipSilence(final boolean newSkipSilence) { + this.skipSilence = newSkipSilence; + binding.skipSilenceCheckbox.setChecked(newSkipSilence); + } + + @SuppressWarnings("SameParameterValue") // this method was written to be reusable + private void bindCheckboxWithBoolPref( + @NonNull final CheckBox checkBox, + @StringRes final int resId, + final boolean defaultValue, + @NonNull final Consumer onInitialValueOrValueChange + ) { + final boolean prefValue = PreferenceManager + .getDefaultSharedPreferences(requireContext()) + .getBoolean(getString(resId), defaultValue); + + checkBox.setChecked(prefValue); + + onInitialValueOrValueChange.accept(prefValue); + + checkBox.setOnCheckedChangeListener((compoundButton, isChecked) -> { + // save whether pitch and tempo are unhooked or not + PreferenceManager.getDefaultSharedPreferences(requireContext()) + .edit() + .putBoolean(getString(resId), isChecked) + .apply(); + + onInitialValueOrValueChange.accept(isChecked); + }); + } + + /** + * Ensures that the slider hook is valid and if not sets and updates the sliders accordingly. + *
+ * You have to ensure by yourself that the hooking is active. + */ + private void ensureHookIsValidAndUpdateCallBack() { + if (tempo != pitchPercent) { + setSliders(Math.min(tempo, pitchPercent)); + updateCallback(); } } @@ -350,142 +493,106 @@ public class PlaybackParameterDialog extends DialogFragment { // Sliders //////////////////////////////////////////////////////////////////////////*/ - private SeekBar.OnSeekBarChangeListener getOnTempoChangedListener() { - return new SeekBar.OnSeekBarChangeListener() { + private SeekBar.OnSeekBarChangeListener getTempoOrPitchSeekbarChangeListener( + final SliderStrategy sliderStrategy, + final DoubleConsumer newValueConsumer + ) { + 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) { - onTempoSliderUpdated(currentTempo); - setCurrentPlaybackParameters(); + if (fromUser) { // ensure that the user triggered the change + newValueConsumer.accept(sliderStrategy.valueOf(progress)); + updateCallback(); } } - - @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() { - @Override - public void onProgressChanged(final SeekBar seekBar, final int progress, - final boolean fromUser) { - final double currentPitch = strategy.valueOf(progress); - if (fromUser) { // this change is first in chain - onPitchSliderUpdated(currentPitch); - setCurrentPlaybackParameters(); - } - } - - @Override - public void onStartTrackingTouch(final SeekBar seekBar) { - // Do Nothing. - } - - @Override - public void onStopTrackingTouch(final SeekBar seekBar) { - // Do Nothing. - } }; } private void onTempoSliderUpdated(final double newTempo) { - if (unhookingCheckbox == null) { - return; - } - if (!unhookingCheckbox.isChecked()) { + if (!binding.unhookCheckbox.isChecked()) { setSliders(newTempo); } else { - setTempoSlider(newTempo); + setAndUpdateTempo(newTempo); } } - private void onPitchSliderUpdated(final double newPitch) { - if (unhookingCheckbox == null) { - return; - } - if (!unhookingCheckbox.isChecked()) { + private void onPitchPercentSliderUpdated(final double newPitch) { + if (!binding.unhookCheckbox.isChecked()) { setSliders(newPitch); } else { - setPitchSlider(newPitch); + setAndUpdatePitch(newPitch); } } private void setSliders(final double newValue) { - setTempoSlider(newValue); - setPitchSlider(newValue); + setAndUpdateTempo(newValue); + setAndUpdatePitch(newValue); } - private void setTempoSlider(final double newTempo) { - if (tempoSlider == null) { - return; - } - tempoSlider.setProgress(strategy.progressOf(newTempo)); + private void setAndUpdateTempo(final double newTempo) { + this.tempo = calcValidTempo(newTempo); + + binding.tempoSeekbar.setProgress(QUADRATIC_STRATEGY.progressOf(tempo)); + setText(binding.tempoCurrentText, PlayerHelper::formatSpeed, tempo); } - private void setPitchSlider(final double newPitch) { - if (pitchSlider == null) { - return; + private void setAndUpdatePitch(final double newPitch) { + this.pitchPercent = calcValidPitch(newPitch); + + binding.pitchPercentSeekbar.setProgress(QUADRATIC_STRATEGY.progressOf(pitchPercent)); + binding.pitchSemitoneSeekbar.setProgress(SEMITONE_STRATEGY.progressOf(pitchPercent)); + setText(binding.pitchPercentCurrentText, + PlayerHelper::formatPitch, + pitchPercent); + setText(binding.pitchSemitoneCurrentText, + PlayerSemitoneHelper::formatPitchSemitones, + pitchPercent); + } + + private double calcValidTempo(final double newTempo) { + return Math.max(MIN_PITCH_OR_SPEED, Math.min(MAX_PITCH_OR_SPEED, newTempo)); + } + + private double calcValidPitch(final double newPitch) { + final double calcPitch = + Math.max(MIN_PITCH_OR_SPEED, Math.min(MAX_PITCH_OR_SPEED, newPitch)); + + if (!isCurrentPitchControlModeSemitone()) { + return calcPitch; } - pitchSlider.setProgress(strategy.progressOf(newPitch)); + + return PlayerSemitoneHelper.semitonesToPercent( + PlayerSemitoneHelper.percentToSemitones(calcPitch)); } /*////////////////////////////////////////////////////////////////////////// // Helper //////////////////////////////////////////////////////////////////////////*/ - private void setCurrentPlaybackParameters() { - setPlaybackParameters(getCurrentTempo(), getCurrentPitch(), getCurrentSkipSilence()); - } - - private void setPlaybackParameters(final double newTempo, final double newPitch, - final boolean skipSilence) { - if (callback != null && tempoCurrentText != null && pitchCurrentText != null) { - if (DEBUG) { - Log.d(TAG, "Setting playback parameters to " - + "tempo=[" + newTempo + "], " - + "pitch=[" + newPitch + "]"); - } - - tempoCurrentText.setText(PlayerHelper.formatSpeed(newTempo)); - pitchCurrentText.setText(PlayerHelper.formatPitch(newPitch)); - callback.onPlaybackParameterChanged((float) newTempo, (float) newPitch, skipSilence); + private void updateCallback() { + if (callback == null) { + return; } - } - - private double getCurrentTempo() { - return tempoSlider == null ? tempo : strategy.valueOf(tempoSlider.getProgress()); - } - - private double getCurrentPitch() { - return pitchSlider == null ? pitch : strategy.valueOf(pitchSlider.getProgress()); - } - - private double getCurrentStepSize() { - return stepSize; - } - - private boolean getCurrentSkipSilence() { - return skipSilenceCheckbox != null && skipSilenceCheckbox.isChecked(); + if (DEBUG) { + Log.d(TAG, "Updating callback: " + + "tempo = " + tempo + ", " + + "pitchPercent = " + pitchPercent + ", " + + "skipSilence = " + skipSilence + ); + } + callback.onPlaybackParameterChanged((float) tempo, (float) pitchPercent, skipSilence); } @NonNull private static String getStepUpPercentString(final double percent) { - return STEP_UP_SIGN + getPercentString(percent); + return '+' + getPercentString(percent); } @NonNull private static String getStepDownPercentString(final double percent) { - return STEP_DOWN_SIGN + getPercentString(percent); + return '-' + getPercentString(percent); } @NonNull 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..88f25e194 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 @@ -1,9 +1,13 @@ package org.schabi.newpipe.player.helper; +import static org.schabi.newpipe.MainActivity.DEBUG; + import android.content.Context; +import android.util.Log; -import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.database.StandaloneDatabaseProvider; import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.source.SingleSampleMediaSource; import com.google.android.exoplayer2.source.dash.DashMediaSource; @@ -13,11 +17,23 @@ 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.DefaultLoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.DefaultDataSource; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; +import com.google.android.exoplayer2.upstream.cache.SimpleCache; + +import org.schabi.newpipe.DownloaderImpl; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubePostLiveStreamDvrDashManifestCreator; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeProgressiveDashManifestCreator; +import org.schabi.newpipe.player.datasource.NonUriHlsDataSourceFactory; +import org.schabi.newpipe.player.datasource.YoutubeHttpDataSource; + +import java.io.File; public class PlayerDataSource { + public static final String TAG = PlayerDataSource.class.getSimpleName(); public static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000; @@ -28,74 +44,174 @@ public class PlayerDataSource { * early. */ private static final double PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT = 15; - private static final int MANIFEST_MINIMUM_RETRY = 5; - private static final int EXTRACTOR_MINIMUM_RETRY = Integer.MAX_VALUE; - private final DataSource.Factory cacheDataSourceFactory; + /** + * The maximum number of generated manifests per cache, in + * {@link YoutubeProgressiveDashManifestCreator}, {@link YoutubeOtfDashManifestCreator} and + * {@link YoutubePostLiveStreamDvrDashManifestCreator}. + */ + private static final int MAX_MANIFEST_CACHE_SIZE = 500; + + /** + * The folder name in which the ExoPlayer cache will be written. + */ + private static final String CACHE_FOLDER_NAME = "exoplayer"; + + /** + * The {@link SimpleCache} instance which will be used to build + * {@link com.google.android.exoplayer2.upstream.cache.CacheDataSource}s instances (with + * {@link CacheFactory}). + */ + private static SimpleCache cache; + + + private final int progressiveLoadIntervalBytes; + + // Generic Data Source Factories (without or with cache) private final DataSource.Factory cachelessDataSourceFactory; + private final CacheFactory cacheDataSourceFactory; - public PlayerDataSource(@NonNull final Context context, @NonNull final String userAgent, - @NonNull final TransferListener transferListener) { - cacheDataSourceFactory = new CacheFactory(context, userAgent, transferListener); - cachelessDataSourceFactory - = new DefaultDataSourceFactory(context, userAgent, transferListener); + // YouTube-specific Data Source Factories (with cache) + // They use YoutubeHttpDataSource.Factory, with different parameters each + private final CacheFactory ytHlsCacheDataSourceFactory; + private final CacheFactory ytDashCacheDataSourceFactory; + private final CacheFactory ytProgressiveDashCacheDataSourceFactory; + + + public PlayerDataSource(final Context context, + final TransferListener transferListener) { + + progressiveLoadIntervalBytes = PlayerHelper.getProgressiveLoadIntervalBytes(context); + + // make sure the static cache was created: needed by CacheFactories below + instantiateCacheIfNeeded(context); + + // generic data source factories use DefaultHttpDataSource.Factory + cachelessDataSourceFactory = new DefaultDataSource.Factory(context, + new DefaultHttpDataSource.Factory().setUserAgent(DownloaderImpl.USER_AGENT)) + .setTransferListener(transferListener); + cacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + new DefaultHttpDataSource.Factory().setUserAgent(DownloaderImpl.USER_AGENT)); + + // YouTube-specific data source factories use getYoutubeHttpDataSourceFactory() + ytHlsCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + getYoutubeHttpDataSourceFactory(false, false)); + ytDashCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + getYoutubeHttpDataSourceFactory(true, true)); + ytProgressiveDashCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + getYoutubeHttpDataSourceFactory(false, true)); + + // set the maximum size to manifest creators + YoutubeProgressiveDashManifestCreator.getCache().setMaximumSize(MAX_MANIFEST_CACHE_SIZE); + YoutubeOtfDashManifestCreator.getCache().setMaximumSize(MAX_MANIFEST_CACHE_SIZE); + YoutubePostLiveStreamDvrDashManifestCreator.getCache().setMaximumSize( + MAX_MANIFEST_CACHE_SIZE); } + + //region Live media source factories public SsMediaSource.Factory getLiveSsMediaSourceFactory() { - return new SsMediaSource.Factory( - new DefaultSsChunkSource.Factory(cachelessDataSourceFactory), - cachelessDataSourceFactory - ) - .setLoadErrorHandlingPolicy( - new DefaultLoadErrorHandlingPolicy(MANIFEST_MINIMUM_RETRY)) - .setLivePresentationDelayMs(LIVE_STREAM_EDGE_GAP_MILLIS); + return getSSMediaSourceFactory().setLivePresentationDelayMs(LIVE_STREAM_EDGE_GAP_MILLIS); } public HlsMediaSource.Factory getLiveHlsMediaSourceFactory() { return new HlsMediaSource.Factory(cachelessDataSourceFactory) .setAllowChunklessPreparation(true) - .setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy( - MANIFEST_MINIMUM_RETRY)) .setPlaylistTrackerFactory((dataSourceFactory, loadErrorHandlingPolicy, playlistParserFactory) -> new DefaultHlsPlaylistTracker(dataSourceFactory, loadErrorHandlingPolicy, - playlistParserFactory, PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT) - ); + playlistParserFactory, + PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT)); } public DashMediaSource.Factory getLiveDashMediaSourceFactory() { return new DashMediaSource.Factory( getDefaultDashChunkSourceFactory(cachelessDataSourceFactory), - cachelessDataSourceFactory - ) - .setLoadErrorHandlingPolicy( - new DefaultLoadErrorHandlingPolicy(MANIFEST_MINIMUM_RETRY)); + cachelessDataSourceFactory); } + //endregion - private DefaultDashChunkSource.Factory getDefaultDashChunkSourceFactory( - final DataSource.Factory dataSourceFactory - ) { - return new DefaultDashChunkSource.Factory(dataSourceFactory); - } - public HlsMediaSource.Factory getHlsMediaSourceFactory() { + //region Generic media source factories + public HlsMediaSource.Factory getHlsMediaSourceFactory( + @Nullable final NonUriHlsDataSourceFactory.Builder hlsDataSourceFactoryBuilder) { + if (hlsDataSourceFactoryBuilder != null) { + hlsDataSourceFactoryBuilder.setDataSourceFactory(cacheDataSourceFactory); + return new HlsMediaSource.Factory(hlsDataSourceFactoryBuilder.build()); + } + return new HlsMediaSource.Factory(cacheDataSourceFactory); } public DashMediaSource.Factory getDashMediaSourceFactory() { return new DashMediaSource.Factory( getDefaultDashChunkSourceFactory(cacheDataSourceFactory), - cacheDataSourceFactory - ); + cacheDataSourceFactory); } - public ProgressiveMediaSource.Factory getExtractorMediaSourceFactory() { + public ProgressiveMediaSource.Factory getProgressiveMediaSourceFactory() { return new ProgressiveMediaSource.Factory(cacheDataSourceFactory) - .setLoadErrorHandlingPolicy( - new DefaultLoadErrorHandlingPolicy(EXTRACTOR_MINIMUM_RETRY)); + .setContinueLoadingCheckIntervalBytes(progressiveLoadIntervalBytes); } - public SingleSampleMediaSource.Factory getSampleMediaSourceFactory() { + public SsMediaSource.Factory getSSMediaSourceFactory() { + return new SsMediaSource.Factory( + new DefaultSsChunkSource.Factory(cachelessDataSourceFactory), + cachelessDataSourceFactory); + } + + public SingleSampleMediaSource.Factory getSingleSampleMediaSourceFactory() { return new SingleSampleMediaSource.Factory(cacheDataSourceFactory); } + //endregion + + + //region YouTube media source factories + public HlsMediaSource.Factory getYoutubeHlsMediaSourceFactory() { + return new HlsMediaSource.Factory(ytHlsCacheDataSourceFactory); + } + + public DashMediaSource.Factory getYoutubeDashMediaSourceFactory() { + return new DashMediaSource.Factory( + getDefaultDashChunkSourceFactory(ytDashCacheDataSourceFactory), + ytDashCacheDataSourceFactory); + } + + public ProgressiveMediaSource.Factory getYoutubeProgressiveMediaSourceFactory() { + return new ProgressiveMediaSource.Factory(ytProgressiveDashCacheDataSourceFactory) + .setContinueLoadingCheckIntervalBytes(progressiveLoadIntervalBytes); + } + //endregion + + + //region Static methods + private static DefaultDashChunkSource.Factory getDefaultDashChunkSourceFactory( + final DataSource.Factory dataSourceFactory) { + return new DefaultDashChunkSource.Factory(dataSourceFactory); + } + + private static YoutubeHttpDataSource.Factory getYoutubeHttpDataSourceFactory( + final boolean rangeParameterEnabled, + final boolean rnParameterEnabled) { + return new YoutubeHttpDataSource.Factory() + .setRangeParameterEnabled(rangeParameterEnabled) + .setRnParameterEnabled(rnParameterEnabled); + } + + private static void instantiateCacheIfNeeded(final Context context) { + if (cache == null) { + final File cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); + if (DEBUG) { + Log.d(TAG, "instantiateCacheIfNeeded: cacheDir = " + cacheDir.getAbsolutePath()); + } + if (!cacheDir.exists() && !cacheDir.mkdir()) { + Log.w(TAG, "instantiateCacheIfNeeded: could not create cache dir"); + } + + final LeastRecentlyUsedCacheEvictor evictor + = new LeastRecentlyUsedCacheEvictor(PlayerHelper.getPreferredCacheSize()); + cache = new SimpleCache(cacheDir, evictor, new StandaloneDatabaseProvider(context)); + } + } + //endregion } 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..2131861bf 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; @@ -44,11 +45,9 @@ import com.google.android.exoplayer2.util.MimeTypes; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.MediaFormat; -import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.SubtitlesStream; -import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.extractor.utils.Utils; import org.schabi.newpipe.player.MainPlayer; import org.schabi.newpipe.player.Player; @@ -77,6 +76,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}) @@ -95,12 +108,14 @@ public final class PlayerHelper { int MINIMIZE_ON_EXIT_MODE_POPUP = 2; } - private PlayerHelper() { } + private PlayerHelper() { + } //////////////////////////////////////////////////////////////////////////// // Exposed helpers //////////////////////////////////////////////////////////////////////////// + @NonNull public static String getTimeString(final int milliSeconds) { final int seconds = (milliSeconds % 60000) / 1000; final int minutes = (milliSeconds % 3600000) / 60000; @@ -116,15 +131,18 @@ public final class PlayerHelper { ).toString(); } + @NonNull public static String formatSpeed(final double speed) { return SPEED_FORMATTER.format(speed); } + @NonNull public static String formatPitch(final double pitch) { return PITCH_FORMATTER.format(pitch); } - public static String subtitleMimeTypesOf(final MediaFormat format) { + @NonNull + public static String subtitleMimeTypesOf(@NonNull final MediaFormat format) { switch (format) { case VTT: return MimeTypes.TEXT_VTT; @@ -143,6 +161,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) { @@ -160,18 +193,6 @@ public final class PlayerHelper { } } - @NonNull - public static String cacheKeyOf(@NonNull final StreamInfo info, - @NonNull final VideoStream video) { - return info.getUrl() + video.getResolution() + video.getFormat().getName(); - } - - @NonNull - public static String cacheKeyOf(@NonNull final StreamInfo info, - @NonNull final AudioStream audio) { - return info.getUrl() + audio.getAverageBitrate() + audio.getFormat().getName(); - } - /** * Given a {@link StreamInfo} and the existing queue items, * provide the {@link SinglePlayQueue} consisting of the next video for auto queueing. @@ -203,7 +224,7 @@ public final class PlayerHelper { return null; } - if (relatedItems.get(0) != null && relatedItems.get(0) instanceof StreamInfoItem + if (relatedItems.get(0) instanceof StreamInfoItem && !urls.contains(relatedItems.get(0).getUrl())) { return getAutoQueuedSinglePlayQueue((StreamInfoItem) relatedItems.get(0)); } @@ -305,6 +326,7 @@ public final class PlayerHelper { return 2 * 1024 * 1024L; // ExoPlayer CacheDataSink.MIN_RECOMMENDED_FRAGMENT_SIZE } + @NonNull public static ExoTrackSelection.Factory getQualitySelector() { return new AdaptiveTrackSelection.Factory( 1000, @@ -359,7 +381,7 @@ public final class PlayerHelper { /** * @param context the Android context * @return the screen brightness to use. A value less than 0 (the default) means to use the - * preferred screen brightness + * preferred screen brightness */ public static float getScreenBrightness(@NonNull final Context context) { final SharedPreferences sp = getPreferences(context); @@ -391,6 +413,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 //////////////////////////////////////////////////////////////////////////// @@ -437,7 +472,8 @@ public final class PlayerHelper { return REPEAT_MODE_ONE; case REPEAT_MODE_ONE: return REPEAT_MODE_ALL; - case REPEAT_MODE_ALL: default: + case REPEAT_MODE_ALL: + default: return REPEAT_MODE_OFF; } } @@ -505,7 +541,7 @@ public final class PlayerHelper { player.getContext().getResources().getDimension(R.dimen.popup_default_width); final float popupWidth = popupRememberSizeAndPos ? player.getPrefs().getFloat(player.getContext().getString( - R.string.popup_saved_width_key), defaultSize) + R.string.popup_saved_width_key), defaultSize) : defaultSize; final float popupHeight = getMinimumVideoHeight(popupWidth); @@ -521,10 +557,10 @@ public final class PlayerHelper { final int centerY = (int) (player.getScreenHeight() / 2f - popupHeight / 2f); popupLayoutParams.x = popupRememberSizeAndPos ? player.getPrefs().getInt(player.getContext().getString( - R.string.popup_saved_x_key), centerX) : centerX; + R.string.popup_saved_x_key), centerX) : centerX; popupLayoutParams.y = popupRememberSizeAndPos ? player.getPrefs().getInt(player.getContext().getString( - R.string.popup_saved_y_key), centerY) : centerY; + R.string.popup_saved_y_key), centerY) : centerY; return popupLayoutParams; } @@ -558,6 +594,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/helper/PlayerSemitoneHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerSemitoneHelper.java new file mode 100644 index 000000000..f3a71d7cd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerSemitoneHelper.java @@ -0,0 +1,38 @@ +package org.schabi.newpipe.player.helper; + +/** + * Converts between percent and 12-tone equal temperament semitones. + *
+ * @see + * + * Wikipedia: Equal temperament#Twelve-tone equal temperament + * + */ +public final class PlayerSemitoneHelper { + public static final int SEMITONE_COUNT = 12; + + private PlayerSemitoneHelper() { + // No impl + } + + public static String formatPitchSemitones(final double percent) { + return formatPitchSemitones(percentToSemitones(percent)); + } + + public static String formatPitchSemitones(final int semitones) { + return semitones > 0 ? "+" + semitones : "" + semitones; + } + + public static double semitonesToPercent(final int semitones) { + return Math.pow(2, ensureSemitonesInRange(semitones) / (double) SEMITONE_COUNT); + } + + public static int percentToSemitones(final double percent) { + return ensureSemitonesInRange( + (int) Math.round(SEMITONE_COUNT * Math.log(percent) / Math.log(2))); + } + + private static int ensureSemitonesInRange(final int semitones) { + return Math.max(-SEMITONE_COUNT, Math.min(SEMITONE_COUNT, semitones)); + } +} 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..43e8288e6 --- /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.getResolution() + } + + 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 9d1543c46..f8262426e 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 @@ -12,11 +12,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; @@ -202,7 +203,7 @@ public class MediaSourceManager { //////////////////////////////////////////////////////////////////////////*/ private Subscriber getReactor() { - return new Subscriber() { + return new Subscriber<>() { @Override public void onSubscribe(@NonNull final Subscription d) { playQueueReactor.cancel(); @@ -216,10 +217,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() { + } }; } @@ -299,11 +302,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); } @@ -322,7 +325,7 @@ public class MediaSourceManager { isBlocked.set(true); } - private void maybeUnblock() { + private boolean maybeUnblock() { if (DEBUG) { Log.d(TAG, "maybeUnblock() called."); } @@ -330,14 +333,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."); } @@ -347,13 +353,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); } } @@ -424,23 +430,32 @@ 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()); stream.setVideoSegments(SponsorBlockUtils.getYouTubeVideoSegments(context, streamInfo)); - 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, @@ -488,23 +503,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 79ac735ed..dc583881e 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 @@ -55,6 +55,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/PlayQueueItemBuilder.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java index f2e98d866..e7aeb9638 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java @@ -5,9 +5,9 @@ import android.text.TextUtils; import android.view.MotionEvent; import android.view.View; -import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.ServiceHelper; public class PlayQueueItemBuilder { private static final String TAG = PlayQueueItemBuilder.class.toString(); @@ -25,7 +25,7 @@ public class PlayQueueItemBuilder { holder.itemVideoTitleView.setText(item.getTitle()); } holder.itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.getUploader(), - NewPipe.getNameOfService(item.getServiceId()))); + ServiceHelper.getNameOfServiceById(item.getServiceId()))); if (item.getDuration() > 0) { holder.itemDurationView.setText(Localization.getDurationString(item.getDuration())); 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..934beba19 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 @@ -1,20 +1,27 @@ package org.schabi.newpipe.player.resolver; +import static org.schabi.newpipe.util.ListHelper.getNonTorrentStreams; + import android.content.Context; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.android.exoplayer2.source.MediaSource; -import org.schabi.newpipe.extractor.MediaFormat; 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; +import java.util.List; + public class AudioPlaybackResolver implements PlaybackResolver { + private static final String TAG = AudioPlaybackResolver.class.getSimpleName(); + @NonNull private final Context context; @NonNull @@ -29,19 +36,27 @@ public class AudioPlaybackResolver implements PlaybackResolver { @Override @Nullable public MediaSource resolve(@NonNull final StreamInfo info) { - final MediaSource liveSource = maybeBuildLiveMediaSource(dataSource, info); + final MediaSource liveSource = PlaybackResolver.maybeBuildLiveMediaSource(dataSource, info); if (liveSource != null) { return liveSource; } - final int index = ListHelper.getDefaultAudioFormat(context, info.getAudioStreams()); + final List audioStreams = getNonTorrentStreams(info.getAudioStreams()); + + final int index = ListHelper.getDefaultAudioFormat(context, audioStreams); if (index < 0 || index >= info.getAudioStreams().size()) { return null; } final AudioStream audio = info.getAudioStreams().get(index); - final MediaSourceTag tag = new MediaSourceTag(info); - return buildMediaSource(dataSource, audio.getUrl(), PlayerHelper.cacheKeyOf(info, audio), - MediaFormat.getSuffixById(audio.getFormatId()), tag); + final MediaItemTag tag = StreamInfoTag.of(info); + + try { + return PlaybackResolver.buildMediaSource( + dataSource, audio, info, PlaybackResolver.cacheKeyOf(info, audio), tag); + } catch (final ResolverException e) { + Log.e(TAG, "Unable to create audio source", e); + return null; + } } } 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..34e7e9bd1 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 @@ -1,48 +1,194 @@ package org.schabi.newpipe.player.resolver; -import android.net.Uri; -import android.text.TextUtils; +import static org.schabi.newpipe.extractor.stream.AudioStream.UNKNOWN_BITRATE; +import static org.schabi.newpipe.extractor.stream.VideoStream.RESOLUTION_UNKNOWN; +import static org.schabi.newpipe.player.helper.PlayerDataSource.LIVE_STREAM_EDGE_GAP_MILLIS; + +import android.net.Uri; +import android.util.Log; -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 com.google.android.exoplayer2.source.ProgressiveMediaSource; +import com.google.android.exoplayer2.source.dash.DashMediaSource; +import com.google.android.exoplayer2.source.dash.manifest.DashManifest; +import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; +import com.google.android.exoplayer2.source.hls.HlsMediaSource; +import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; +import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest; +import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifestParser; +import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.extractor.services.youtube.ItagItem; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.CreationException; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubePostLiveStreamDvrDashManifestCreator; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeProgressiveDashManifestCreator; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.DeliveryMethod; +import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.player.datasource.NonUriHlsDataSourceFactory; 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; -public interface PlaybackResolver extends Resolver { +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +/** + * This interface is just a shorthand for {@link Resolver} with {@link StreamInfo} as source and + * {@link MediaSource} as product. It contains many static methods that can be used by classes + * implementing this interface, and nothing else. + */ +public interface PlaybackResolver extends Resolver { + String TAG = PlaybackResolver.class.getSimpleName(); + + + //region Cache key generation + private static StringBuilder commonCacheKeyOf(final StreamInfo info, + final Stream stream, + final boolean resolutionOrBitrateUnknown) { + // stream info service id + final StringBuilder cacheKey = new StringBuilder(info.getServiceId()); + + // stream info id + cacheKey.append(" "); + cacheKey.append(info.getId()); + + // stream id (even if unknown) + cacheKey.append(" "); + cacheKey.append(stream.getId()); + + // mediaFormat (if not null) + final MediaFormat mediaFormat = stream.getFormat(); + if (mediaFormat != null) { + cacheKey.append(" "); + cacheKey.append(mediaFormat.getName()); + } + + // content (only if other information is missing) + // If the media format and the resolution/bitrate are both missing, then we don't have + // enough information to distinguish this stream from other streams. + // So, only in that case, we use the content (i.e. url or manifest) to differentiate + // between streams. + // Note that if the content were used even when other information is present, then two + // streams with the same stats but with different contents (e.g. because the url was + // refreshed) will be considered different (i.e. with a different cacheKey), making the + // cache useless. + if (resolutionOrBitrateUnknown && mediaFormat == null) { + cacheKey.append(" "); + cacheKey.append(Objects.hash(stream.getContent(), stream.getManifestUrl())); + } + + return cacheKey; + } + + /** + * Builds the cache key of a {@link VideoStream video stream}. + * + *

+ * A cache key is unique to the features of the provided video stream, and when possible + * independent of transient parameters (such as the URL of the stream). + * This ensures that there are no conflicts, but also that the cache is used as much as + * possible: the same cache should be used for two streams which have the same features but + * e.g. a different URL, since the URL might have been reloaded in the meantime, but the stream + * actually referenced by the URL is still the same. + *

+ * + * @param info the {@link StreamInfo stream info}, to distinguish between streams with + * the same features but coming from different stream infos + * @param videoStream the {@link VideoStream video stream} for which the cache key should be + * created + * @return a key to be used to store the cache of the provided {@link VideoStream video stream} + */ + static String cacheKeyOf(final StreamInfo info, final VideoStream videoStream) { + final boolean resolutionUnknown = videoStream.getResolution().equals(RESOLUTION_UNKNOWN); + final StringBuilder cacheKey = commonCacheKeyOf(info, videoStream, resolutionUnknown); + + // resolution (if known) + if (!resolutionUnknown) { + cacheKey.append(" "); + cacheKey.append(videoStream.getResolution()); + } + + // isVideoOnly + cacheKey.append(" "); + cacheKey.append(videoStream.isVideoOnly()); + + return cacheKey.toString(); + } + + /** + * Builds the cache key of an audio stream. + * + *

+ * A cache key is unique to the features of the provided {@link AudioStream audio stream}, and + * when possible independent of transient parameters (such as the URL of the stream). + * This ensures that there are no conflicts, but also that the cache is used as much as + * possible: the same cache should be used for two streams which have the same features but + * e.g. a different URL, since the URL might have been reloaded in the meantime, but the stream + * actually referenced by the URL is still the same. + *

+ * + * @param info the {@link StreamInfo stream info}, to distinguish between streams with + * the same features but coming from different stream infos + * @param audioStream the {@link AudioStream audio stream} for which the cache key should be + * created + * @return a key to be used to store the cache of the provided {@link AudioStream audio stream} + */ + static String cacheKeyOf(final StreamInfo info, final AudioStream audioStream) { + final boolean averageBitrateUnknown = audioStream.getAverageBitrate() == UNKNOWN_BITRATE; + final StringBuilder cacheKey = commonCacheKeyOf(info, audioStream, averageBitrateUnknown); + + // averageBitrate (if known) + if (!averageBitrateUnknown) { + cacheKey.append(" "); + cacheKey.append(audioStream.getAverageBitrate()); + } + + return cacheKey.toString(); + } + //endregion + + + //region Live media sources @Nullable - default MediaSource maybeBuildLiveMediaSource(@NonNull final PlayerDataSource dataSource, - @NonNull final StreamInfo info) { - final StreamType streamType = info.getStreamType(); - if (!StreamTypeUtil.isLiveStream(streamType)) { + static MediaSource maybeBuildLiveMediaSource(final PlayerDataSource dataSource, + final StreamInfo info) { + if (!StreamTypeUtil.isLiveStream(info.getStreamType())) { return null; } - final MediaSourceTag tag = new MediaSourceTag(info); - if (!info.getHlsUrl().isEmpty()) { - return buildLiveMediaSource(dataSource, info.getHlsUrl(), C.TYPE_HLS, tag); - } else if (!info.getDashMpdUrl().isEmpty()) { - return buildLiveMediaSource(dataSource, info.getDashMpdUrl(), C.TYPE_DASH, tag); + try { + final StreamInfoTag tag = StreamInfoTag.of(info); + if (!info.getHlsUrl().isEmpty()) { + return buildLiveMediaSource(dataSource, info.getHlsUrl(), C.TYPE_HLS, tag); + } else if (!info.getDashMpdUrl().isEmpty()) { + return buildLiveMediaSource(dataSource, info.getDashMpdUrl(), C.TYPE_DASH, tag); + } + } catch (final Exception e) { + Log.w(TAG, "Error when generating live media source, falling back to standard sources", + e); } return null; } - @NonNull - default MediaSource buildLiveMediaSource(@NonNull final PlayerDataSource dataSource, - @NonNull final String sourceUrl, - @C.ContentType final int type, - @NonNull final MediaSourceTag metadata) { - final MediaSourceFactory factory; + static MediaSource buildLiveMediaSource(final PlayerDataSource dataSource, + final String sourceUrl, + @C.ContentType final int type, + final MediaItemTag metadata) throws ResolverException { + final MediaSource.Factory factory; switch (type) { case C.TYPE_SS: factory = dataSource.getLiveSsMediaSourceFactory(); @@ -53,53 +199,330 @@ public interface PlaybackResolver extends Resolver { case C.TYPE_HLS: factory = dataSource.getLiveHlsMediaSourceFactory(); break; + case C.TYPE_OTHER: + case C.TYPE_RTSP: default: - throw new IllegalStateException("Unsupported type: " + type); + throw new ResolverException("Unsupported type: " + type); } return factory.createMediaSource( new MediaItem.Builder() .setTag(metadata) .setUri(Uri.parse(sourceUrl)) - .setLiveTargetOffsetMs(PlayerDataSource.LIVE_STREAM_EDGE_GAP_MILLIS) - .build() - ); + .setLiveConfiguration( + new MediaItem.LiveConfiguration.Builder() + .setTargetOffsetMs(LIVE_STREAM_EDGE_GAP_MILLIS) + .build()) + .build()); } + //endregion - @NonNull - default MediaSource buildMediaSource(@NonNull final PlayerDataSource dataSource, - @NonNull final String sourceUrl, - @NonNull final String cacheKey, - @NonNull final String overrideExtension, - @NonNull final MediaSourceTag metadata) { - final Uri uri = Uri.parse(sourceUrl); - @C.ContentType final int type = TextUtils.isEmpty(overrideExtension) - ? Util.inferContentType(uri) : Util.inferContentType("." + overrideExtension); - final MediaSourceFactory factory; - switch (type) { - case C.TYPE_SS: - factory = dataSource.getLiveSsMediaSourceFactory(); - break; - case C.TYPE_DASH: - factory = dataSource.getDashMediaSourceFactory(); - break; - case C.TYPE_HLS: - factory = dataSource.getHlsMediaSourceFactory(); - break; - case C.TYPE_OTHER: - factory = dataSource.getExtractorMediaSourceFactory(); - break; - default: - throw new IllegalStateException("Unsupported type: " + type); + //region Generic media sources + static MediaSource buildMediaSource(final PlayerDataSource dataSource, + final Stream stream, + final StreamInfo streamInfo, + final String cacheKey, + final MediaItemTag metadata) throws ResolverException { + if (streamInfo.getService() == ServiceList.YouTube) { + return createYoutubeMediaSource(stream, streamInfo, dataSource, cacheKey, metadata); } - return factory.createMediaSource( - new MediaItem.Builder() - .setTag(metadata) - .setUri(uri) - .setCustomCacheKey(cacheKey) - .build() - ); + final DeliveryMethod deliveryMethod = stream.getDeliveryMethod(); + switch (deliveryMethod) { + case PROGRESSIVE_HTTP: + return buildProgressiveMediaSource(dataSource, stream, cacheKey, metadata); + case DASH: + return buildDashMediaSource(dataSource, stream, cacheKey, metadata); + case HLS: + return buildHlsMediaSource(dataSource, stream, cacheKey, metadata); + case SS: + return buildSSMediaSource(dataSource, stream, cacheKey, metadata); + // Torrent streams are not supported by ExoPlayer + default: + throw new ResolverException("Unsupported delivery type: " + deliveryMethod); + } } + + private static ProgressiveMediaSource buildProgressiveMediaSource( + final PlayerDataSource dataSource, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) throws ResolverException { + if (!stream.isUrl()) { + throw new ResolverException("Non URI progressive contents are not supported"); + } + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); + return dataSource.getProgressiveMediaSourceFactory().createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + + private static DashMediaSource buildDashMediaSource(final PlayerDataSource dataSource, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) + throws ResolverException { + + if (stream.isUrl()) { + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); + return dataSource.getDashMediaSourceFactory().createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + + try { + return dataSource.getDashMediaSourceFactory().createMediaSource( + createDashManifest(stream.getContent(), stream), + new MediaItem.Builder() + .setTag(metadata) + .setUri(manifestUrlToUri(stream.getManifestUrl())) + .setCustomCacheKey(cacheKey) + .build()); + } catch (final IOException e) { + throw new ResolverException( + "Could not create a DASH media source/manifest from the manifest text", e); + } + } + + private static DashManifest createDashManifest(final String manifestContent, + final Stream stream) throws IOException { + return new DashManifestParser().parse(manifestUrlToUri(stream.getManifestUrl()), + new ByteArrayInputStream(manifestContent.getBytes(StandardCharsets.UTF_8))); + } + + private static HlsMediaSource buildHlsMediaSource(final PlayerDataSource dataSource, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) + throws ResolverException { + if (stream.isUrl()) { + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); + return dataSource.getHlsMediaSourceFactory(null).createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + + final NonUriHlsDataSourceFactory.Builder hlsDataSourceFactoryBuilder = + new NonUriHlsDataSourceFactory.Builder(); + hlsDataSourceFactoryBuilder.setPlaylistString(stream.getContent()); + + return dataSource.getHlsMediaSourceFactory(hlsDataSourceFactoryBuilder) + .createMediaSource(new MediaItem.Builder() + .setTag(metadata) + .setUri(manifestUrlToUri(stream.getManifestUrl())) + .setCustomCacheKey(cacheKey) + .build()); + } + + private static SsMediaSource buildSSMediaSource(final PlayerDataSource dataSource, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) + throws ResolverException { + if (stream.isUrl()) { + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); + return dataSource.getSSMediaSourceFactory().createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + + final Uri manifestUri = manifestUrlToUri(stream.getManifestUrl()); + + final SsManifest smoothStreamingManifest; + try { + final ByteArrayInputStream smoothStreamingManifestInput = new ByteArrayInputStream( + stream.getContent().getBytes(StandardCharsets.UTF_8)); + smoothStreamingManifest = new SsManifestParser().parse(manifestUri, + smoothStreamingManifestInput); + } catch (final IOException e) { + throw new ResolverException("Error when parsing manual SS manifest", e); + } + + return dataSource.getSSMediaSourceFactory().createMediaSource( + smoothStreamingManifest, + new MediaItem.Builder() + .setTag(metadata) + .setUri(manifestUri) + .setCustomCacheKey(cacheKey) + .build()); + } + //endregion + + + //region YouTube media sources + private static MediaSource createYoutubeMediaSource(final Stream stream, + final StreamInfo streamInfo, + final PlayerDataSource dataSource, + final String cacheKey, + final MediaItemTag metadata) + throws ResolverException { + if (!(stream instanceof AudioStream || stream instanceof VideoStream)) { + throw new ResolverException("Generation of YouTube DASH manifest for " + + stream.getClass().getSimpleName() + " is not supported"); + } + + final StreamType streamType = streamInfo.getStreamType(); + if (streamType == StreamType.VIDEO_STREAM) { + return createYoutubeMediaSourceOfVideoStreamType(dataSource, stream, streamInfo, + cacheKey, metadata); + } else if (streamType == StreamType.POST_LIVE_STREAM) { + // If the content is not an URL, uses the DASH delivery method and if the stream type + // of the stream is a post live stream, it means that the content is an ended + // livestream so we need to generate the manifest corresponding to the content + // (which is the last segment of the stream) + + try { + final ItagItem itagItem = Objects.requireNonNull(stream.getItagItem()); + final String manifestString = YoutubePostLiveStreamDvrDashManifestCreator + .fromPostLiveStreamDvrStreamingUrl(stream.getContent(), + itagItem, + itagItem.getTargetDurationSec(), + streamInfo.getDuration()); + return buildYoutubeManualDashMediaSource(dataSource, + createDashManifest(manifestString, stream), stream, cacheKey, + metadata); + } catch (final CreationException | IOException | NullPointerException e) { + throw new ResolverException( + "Error when generating the DASH manifest of YouTube ended live stream", e); + } + } else { + throw new ResolverException( + "DASH manifest generation of YouTube livestreams is not supported"); + } + } + + private static MediaSource createYoutubeMediaSourceOfVideoStreamType( + final PlayerDataSource dataSource, + final Stream stream, + final StreamInfo streamInfo, + final String cacheKey, + final MediaItemTag metadata) throws ResolverException { + final DeliveryMethod deliveryMethod = stream.getDeliveryMethod(); + switch (deliveryMethod) { + case PROGRESSIVE_HTTP: + if ((stream instanceof VideoStream && ((VideoStream) stream).isVideoOnly()) + || stream instanceof AudioStream) { + try { + final String manifestString = YoutubeProgressiveDashManifestCreator + .fromProgressiveStreamingUrl(stream.getContent(), + Objects.requireNonNull(stream.getItagItem()), + streamInfo.getDuration()); + return buildYoutubeManualDashMediaSource(dataSource, + createDashManifest(manifestString, stream), stream, cacheKey, + metadata); + } catch (final CreationException | IOException | NullPointerException e) { + Log.w(TAG, "Error when generating or parsing DASH manifest of " + + "YouTube progressive stream, falling back to a " + + "ProgressiveMediaSource.", e); + return buildYoutubeProgressiveMediaSource(dataSource, stream, cacheKey, + metadata); + } + } else { + // Legacy progressive streams, subtitles are handled by + // VideoPlaybackResolver + return buildYoutubeProgressiveMediaSource(dataSource, stream, cacheKey, + metadata); + } + case DASH: + // If the content is not a URL, uses the DASH delivery method and if the stream + // type of the stream is a video stream, it means the content is an OTF stream + // so we need to generate the manifest corresponding to the content (which is + // the base URL of the OTF stream). + + try { + final String manifestString = YoutubeOtfDashManifestCreator + .fromOtfStreamingUrl(stream.getContent(), + Objects.requireNonNull(stream.getItagItem()), + streamInfo.getDuration()); + return buildYoutubeManualDashMediaSource(dataSource, + createDashManifest(manifestString, stream), stream, cacheKey, + metadata); + } catch (final CreationException | IOException | NullPointerException e) { + Log.e(TAG, + "Error when generating the DASH manifest of YouTube OTF stream", e); + throw new ResolverException( + "Error when generating the DASH manifest of YouTube OTF stream", e); + } + case HLS: + return dataSource.getYoutubeHlsMediaSourceFactory().createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + default: + throw new ResolverException("Unsupported delivery method for YouTube contents: " + + deliveryMethod); + } + } + + private static DashMediaSource buildYoutubeManualDashMediaSource( + final PlayerDataSource dataSource, + final DashManifest dashManifest, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) { + return dataSource.getYoutubeDashMediaSourceFactory().createMediaSource(dashManifest, + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + + private static ProgressiveMediaSource buildYoutubeProgressiveMediaSource( + final PlayerDataSource dataSource, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) { + return dataSource.getYoutubeProgressiveMediaSourceFactory() + .createMediaSource(new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + //endregion + + + //region Utils + private static Uri manifestUrlToUri(final String manifestUrl) { + return Uri.parse(Objects.requireNonNullElse(manifestUrl, "")); + } + + private static void throwResolverExceptionIfUrlNullOrEmpty(@Nullable final String url) + throws ResolverException { + if (url == null) { + throw new ResolverException("Null stream URL"); + } else if (url.isEmpty()) { + throw new ResolverException("Empty stream URL"); + } + } + //endregion + + + //region Resolver exception + final class ResolverException extends Exception { + public ResolverException(final String message) { + super(message); + } + + public ResolverException(final String message, final Throwable cause) { + super(message, cause); + } + } + //endregion } 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..cf7d73558 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 @@ -2,10 +2,12 @@ package org.schabi.newpipe.player.resolver; import android.content.Context; import android.net.Uri; +import android.util.Log; 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,24 +19,38 @@ 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; +import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; +import static org.schabi.newpipe.util.ListHelper.getNonTorrentStreams; public class VideoPlaybackResolver implements PlaybackResolver { + private static final String TAG = VideoPlaybackResolver.class.getSimpleName(); + @NonNull private final Context context; @NonNull 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) { @@ -46,78 +62,114 @@ public class VideoPlaybackResolver implements PlaybackResolver { @Override @Nullable public MediaSource resolve(@NonNull final StreamInfo info) { - final MediaSource liveSource = maybeBuildLiveMediaSource(dataSource, info); + final MediaSource liveSource = PlaybackResolver.maybeBuildLiveMediaSource(dataSource, info); if (liveSource != null) { + streamSourceType = SourceType.LIVE_STREAM; return liveSource; } final List mediaSources = new ArrayList<>(); // Create video stream source - final List videos = ListHelper.getSortedStreamVideosList(context, - info.getVideoStreams(), info.getVideoOnlyStreams(), false); + final List videoStreamsList = ListHelper.getSortedStreamVideosList(context, + getNonTorrentStreams(info.getVideoStreams()), + getNonTorrentStreams(info.getVideoOnlyStreams()), false, true); final int index; - if (videos.isEmpty()) { + if (videoStreamsList.isEmpty()) { index = -1; } else if (playbackQuality == null) { - index = qualityResolver.getDefaultResolutionIndex(videos); + index = qualityResolver.getDefaultResolutionIndex(videoStreamsList); } else { - index = qualityResolver.getOverrideResolutionIndex(videos, getPlaybackQuality()); + index = qualityResolver.getOverrideResolutionIndex(videoStreamsList, + getPlaybackQuality()); } - final MediaSourceTag tag = new MediaSourceTag(info, videos, index); - @Nullable final VideoStream video = tag.getSelectedVideoStream(); + final MediaItemTag tag = StreamInfoTag.of(info, videoStreamsList, index); + @Nullable final VideoStream video = tag.getMaybeQuality() + .map(MediaItemTag.Quality::getSelectedVideoStream) + .orElse(null); if (video != null) { - final MediaSource streamSource = buildMediaSource(dataSource, video.getUrl(), - PlayerHelper.cacheKeyOf(info, video), - MediaFormat.getSuffixById(video.getFormatId()), tag); - mediaSources.add(streamSource); + try { + final MediaSource streamSource = PlaybackResolver.buildMediaSource( + dataSource, video, info, PlaybackResolver.cacheKeyOf(info, video), tag); + mediaSources.add(streamSource); + } catch (final ResolverException e) { + Log.e(TAG, "Unable to create video source", e); + return null; + } } // Create optional audio stream source - final List audioStreams = info.getAudioStreams(); + final List audioStreams = getNonTorrentStreams(info.getAudioStreams()); final AudioStream audio = audioStreams.isEmpty() ? null : audioStreams.get( ListHelper.getDefaultAudioFormat(context, audioStreams)); + // Use the audio stream if there is no video stream, or - // Merge with audio stream in case if video does not contain audio - if (audio != null && (video == null || video.isVideoOnly)) { - final MediaSource audioSource = buildMediaSource(dataSource, audio.getUrl(), - PlayerHelper.cacheKeyOf(info, audio), - MediaFormat.getSuffixById(audio.getFormatId()), tag); - mediaSources.add(audioSource); + // merge with audio stream in case if video does not contain audio + if (audio != null && (video == null || video.isVideoOnly())) { + try { + final MediaSource audioSource = PlaybackResolver.buildMediaSource( + dataSource, audio, info, PlaybackResolver.cacheKeyOf(info, audio), tag); + mediaSources.add(audioSource); + streamSourceType = SourceType.VIDEO_WITH_SEPARATED_AUDIO; + } catch (final ResolverException e) { + Log.e(TAG, "Unable to create audio source", e); + return null; + } + } 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 if (mediaSources.isEmpty()) { return null; } + // Below are auxiliary media sources // Create subtitle sources - if (info.getSubtitles() != null) { - for (final SubtitlesStream subtitle : info.getSubtitles()) { - final String mimeType = PlayerHelper.subtitleMimeTypesOf(subtitle.getFormat()); - if (mimeType == null) { - continue; + final List subtitlesStreams = info.getSubtitles(); + if (subtitlesStreams != null) { + // Torrent and non URL subtitles are not supported by ExoPlayer + final List nonTorrentAndUrlStreams = getUrlAndNonTorrentStreams( + subtitlesStreams); + for (final SubtitlesStream subtitle : nonTorrentAndUrlStreams) { + final MediaFormat mediaFormat = subtitle.getFormat(); + if (mediaFormat != null) { + @C.RoleFlags final 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.getContent())) + .setMimeType(mediaFormat.getMimeType()) + .setRoleFlags(textRoleFlag) + .setLanguage(PlayerHelper.captionLanguageOf(context, subtitle)) + .build(); + final MediaSource textSource = dataSource.getSingleSampleMediaSourceFactory() + .createMediaSource(textMediaItem, TIME_UNSET); + mediaSources.add(textSource); } - final MediaSource textSource = dataSource.getSampleMediaSourceFactory() - .createMediaSource( - new MediaItem.Subtitle(Uri.parse(subtitle.getUrl()), - mimeType, - PlayerHelper.captionLanguageOf(context, subtitle)), - TIME_UNSET); - mediaSources.add(textSource); } } if (mediaSources.size() == 1) { return mediaSources.get(0); } else { - return new MergingMediaSource(mediaSources.toArray( - new MediaSource[0])); + return new MergingMediaSource(true, mediaSources.toArray(new MediaSource[0])); } } + /** + * 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 a534e203e..2288ee5f8 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); PreferenceManager.setDefaultValues(context, R.xml.sponsor_block_category_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 dfc053a62..1ff7947fd 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,9 +183,9 @@ 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.defaultInstance); + selectInstance(PeertubeInstance.DEFAULT_INSTANCE); updateInstanceList(); instanceListAdapter.notifyDataSetChanged(); }) @@ -215,7 +207,7 @@ public class PeertubeInstanceListFragment extends Fragment { new AlertDialog.Builder(c) .setTitle(R.string.peertube_instance_add_title) - .setIcon(R.drawable.place_holder_peertube) + .setIcon(R.drawable.ic_placeholder_peertube) .setView(dialogBinding.getRoot()) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok, (dialog1, which) -> { @@ -419,7 +411,7 @@ public class PeertubeInstanceListFragment extends Fragment { lastChecked = instanceRB; } }); - instanceIconView.setImageResource(R.drawable.place_holder_peertube); + instanceIconView.setImageResource(R.drawable.ic_placeholder_peertube); } @SuppressLint("ClickableViewAccessibility") 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 11a176f32..f321a0918 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); add(SponsorBlockSettingsFragment.class, R.xml.sponsor_block_settings); @@ -118,6 +119,7 @@ public final class SettingsResourceRegistry { return this; } + @NonNull public Class getFragmentClass() { return fragmentClass; } diff --git a/app/src/main/java/org/schabi/newpipe/settings/SponsorBlockCategoriesSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SponsorBlockCategoriesSettingsFragment.java index 7247d5335..af1ec6725 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SponsorBlockCategoriesSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SponsorBlockCategoriesSettingsFragment.java @@ -61,6 +61,10 @@ public class SponsorBlockCategoriesSettingsFragment extends BasePreferenceFragme setColorPreference(editor, R.string.sponsor_block_category_preview_color_key, R.color.preview_segment); + + setColorPreference(editor, + R.string.sponsor_block_category_filler_color_key, + R.color.filler_segment); editor.apply(); 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 5559619a3..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,34 +1,47 @@ package org.schabi.newpipe.settings; import android.os.Bundle; +import android.widget.Toast; import androidx.preference.Preference; +import org.schabi.newpipe.NewVersionWorker; import org.schabi.newpipe.R; -import static org.schabi.newpipe.CheckForNewAppVersion.checkNewVersion; - public class UpdateSettingsFragment extends BasePreferenceFragment { private final Preference.OnPreferenceChangeListener updatePreferenceChange = (preference, checkForUpdates) -> { defaultPreferences.edit() .putBoolean(getString(R.string.update_app_key), (boolean) checkForUpdates).apply(); - if ((boolean) checkForUpdates) { - // Search for updates immediately when update checks are enabled. - // Reset the expire time. This is necessary to check for an update immediately. - defaultPreferences.edit() - .putLong(getString(R.string.update_expiry_key), 0).apply(); - checkNewVersion(); - } + if ((boolean) checkForUpdates) { + checkNewVersionNow(); + } return true; }; + private final Preference.OnPreferenceClickListener manualUpdateClick + = preference -> { + Toast.makeText(getContext(), R.string.checking_updates_toast, Toast.LENGTH_SHORT).show(); + checkNewVersionNow(); + return true; + }; + + private void checkNewVersionNow() { + // Search for updates immediately when update checks are enabled. + // Reset the expire time. This is necessary to check for an update immediately. + defaultPreferences.edit() + .putLong(getString(R.string.update_expiry_key), 0).apply(); + NewVersionWorker.enqueueNewVersionCheckingWork(getContext()); + } + @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { addPreferencesFromResourceRegistry(); - final String updateToggleKey = getString(R.string.update_app_key); - findPreference(updateToggleKey).setOnPreferenceChangeListener(updatePreferenceChange); + findPreference(getString(R.string.update_app_key)) + .setOnPreferenceChangeListener(updatePreferenceChange); + findPreference(getString(R.string.manual_update_key)) + .setOnPreferenceClickListener(manualUpdateClick); } } 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..798d299c0 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 @@ -10,7 +10,6 @@ import android.view.View; import android.view.ViewGroup; import android.widget.CheckBox; import android.widget.ImageView; -import android.widget.LinearLayout; import android.widget.RadioButton; import android.widget.RadioGroup; import android.widget.TextView; @@ -21,11 +20,12 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.content.res.AppCompatResources; import androidx.core.graphics.drawable.DrawableCompat; -import androidx.core.widget.TextViewCompat; import androidx.preference.Preference; import androidx.preference.PreferenceViewHolder; import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.ListRadioIconItemBinding; +import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding; import org.schabi.newpipe.player.MainPlayer; import org.schabi.newpipe.player.NotificationConstants; import org.schabi.newpipe.util.DeviceUtils; @@ -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); @@ -190,13 +190,12 @@ public class NotificationActionsPreference extends Preference { void openActionChooserDialog() { final LayoutInflater inflater = LayoutInflater.from(getContext()); - final LinearLayout rootLayout = (LinearLayout) inflater.inflate( - R.layout.single_choice_dialog_view, null, false); - final RadioGroup radioGroup = rootLayout.findViewById(android.R.id.list); + final SingleChoiceDialogViewBinding binding = + SingleChoiceDialogViewBinding.inflate(inflater); final AlertDialog alertDialog = new AlertDialog.Builder(getContext()) .setTitle(SLOT_TITLES[i]) - .setView(radioGroup) + .setView(binding.getRoot()) .setCancelable(true) .create(); @@ -208,8 +207,8 @@ public class NotificationActionsPreference extends Preference { for (int id = 0; id < NotificationConstants.SLOT_ALLOWED_ACTIONS[i].length; ++id) { final int action = NotificationConstants.SLOT_ALLOWED_ACTIONS[i][id]; - final RadioButton radioButton - = (RadioButton) inflater.inflate(R.layout.list_radio_icon_item, null); + final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater) + .getRoot(); // if present set action icon with correct color if (NotificationConstants.ACTION_ICONS[action] != 0) { @@ -220,8 +219,8 @@ public class NotificationActionsPreference extends Preference { android.R.attr.textColorPrimary); drawable = DrawableCompat.wrap(drawable).mutate(); DrawableCompat.setTint(drawable, color); - TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(radioButton, - null, null, drawable, null); + radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(null, + null, drawable, null); } } @@ -231,7 +230,7 @@ public class NotificationActionsPreference extends Preference { radioButton.setLayoutParams(new RadioGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); radioButton.setOnClickListener(radioButtonsClickListener); - radioGroup.addView(radioButton); + binding.list.addView(radioButton); } alertDialog.show(); 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..289c824ba 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 @@ -1,5 +1,8 @@ package org.schabi.newpipe.settings.tabs; +import static org.schabi.newpipe.settings.tabs.Tab.typeFrom; +import static org.schabi.newpipe.util.ServiceHelper.getNameOfServiceById; + import android.annotation.SuppressLint; import android.app.Dialog; import android.content.Context; @@ -7,7 +10,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 +19,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; @@ -30,7 +31,6 @@ 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.NewPipe; import org.schabi.newpipe.settings.SelectChannelFragment; import org.schabi.newpipe.settings.SelectKioskFragment; import org.schabi.newpipe.settings.SelectPlaylistFragment; @@ -41,8 +41,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -import static org.schabi.newpipe.settings.tabs.Tab.typeFrom; - public class ChooseTabsFragment extends Fragment { private TabsManager tabsManager; @@ -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(); @@ -380,36 +374,31 @@ public class ChooseTabsFragment extends Fragment { return; } - final String tabName; + tabNameView.setText(getTabName(type, tab)); + tabIconView.setImageResource(tab.getTabIconRes(requireContext())); + } + + private String getTabName(@NonNull final Tab.Type type, @NonNull final Tab tab) { switch (type) { case BLANK: - tabName = getString(R.string.blank_page_summary); - break; + return getString(R.string.blank_page_summary); case DEFAULT_KIOSK: - tabName = getString(R.string.default_kiosk_page_summary); - break; + return getString(R.string.default_kiosk_page_summary); case KIOSK: - tabName = NewPipe.getNameOfService(((Tab.KioskTab) tab) - .getKioskServiceId()) + "/" + tab.getTabName(requireContext()); - break; + return getNameOfServiceById(((Tab.KioskTab) tab).getKioskServiceId()) + + "/" + tab.getTabName(requireContext()); case CHANNEL: - tabName = NewPipe.getNameOfService(((Tab.ChannelTab) tab) - .getChannelServiceId()) + "/" + tab.getTabName(requireContext()); - break; + return getNameOfServiceById(((Tab.ChannelTab) tab).getChannelServiceId()) + + "/" + tab.getTabName(requireContext()); case PLAYLIST: final int serviceId = ((Tab.PlaylistTab) tab).getPlaylistServiceId(); final String serviceName = serviceId == -1 ? getString(R.string.local) - : NewPipe.getNameOfService(serviceId); - tabName = serviceName + "/" + tab.getTabName(requireContext()); - break; + : getNameOfServiceById(serviceId); + return serviceName + "/" + tab.getTabName(requireContext()); default: - tabName = tab.getTabName(requireContext()); - break; + return tab.getTabName(requireContext()); } - - tabNameView.setText(tabName); - tabIconView.setImageResource(tab.getTabIconRes(requireContext())); } @SuppressLint("ClickableViewAccessibility") 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/KeyboardUtil.java b/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java index 71c0d3944..a709dc32e 100644 --- a/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java +++ b/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java @@ -24,7 +24,19 @@ public final class KeyboardUtil { if (editText.requestFocus()) { final InputMethodManager imm = ContextCompat.getSystemService(activity, InputMethodManager.class); - imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED); + if (!imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED)) { + /* + * Sometimes the keyboard can't be shown because Android's ImeFocusController is in + * a incorrect state e.g. when animations are disabled or the unfocus event of the + * previous view arrives in the wrong moment (see #7647 for details). + * The invalid state can be fixed by to re-focusing the editText. + */ + editText.clearFocus(); + editText.requestFocus(); + + // Try again + imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED); + } } } 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..eabac8330 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; @@ -12,6 +13,8 @@ import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.DeliveryMethod; +import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.VideoStream; import java.util.ArrayList; @@ -19,7 +22,12 @@ 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.function.Predicate; +import java.util.stream.Collectors; public final class ListHelper { // Video format in order of quality. 0=lowest quality, n=highest quality @@ -32,9 +40,9 @@ public final class ListHelper { // Audio format in order of efficiency. 0=most efficient, n=least efficient 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"); + // Use a HashSet for better performance + private static final Set HIGH_RESOLUTION_LIST = new HashSet<>( + Arrays.asList("1440p", "2160p")); private ListHelper() { } @@ -104,21 +112,70 @@ public final class ListHelper { } } + /** + * Return a {@link Stream} list which uses the given delivery method from a {@link Stream} + * list. + * + * @param streamList the original {@link Stream stream} list + * @param deliveryMethod the {@link DeliveryMethod delivery method} + * @param the item type's class that extends {@link Stream} + * @return a {@link Stream stream} list which uses the given delivery method + */ + @NonNull + public static List getStreamsOfSpecifiedDelivery( + final List streamList, + final DeliveryMethod deliveryMethod) { + return getFilteredStreamList(streamList, + stream -> stream.getDeliveryMethod() == deliveryMethod); + } + + /** + * Return a {@link Stream} list which only contains URL streams and non-torrent streams. + * + * @param streamList the original stream list + * @param the item type's class that extends {@link Stream} + * @return a stream list which only contains URL streams and non-torrent streams + */ + @NonNull + public static List getUrlAndNonTorrentStreams( + final List streamList) { + return getFilteredStreamList(streamList, + stream -> stream.isUrl() && stream.getDeliveryMethod() != DeliveryMethod.TORRENT); + } + + /** + * Return a {@link Stream} list which only contains non-torrent streams. + * + * @param streamList the original stream list + * @param the item type's class that extends {@link Stream} + * @return a stream list which only contains non-torrent streams + */ + @NonNull + public static List getNonTorrentStreams( + final List streamList) { + return getFilteredStreamList(streamList, + stream -> stream.getDeliveryMethod() != DeliveryMethod.TORRENT); + } + /** * 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,13 +185,33 @@ 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); } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ + /** + * Get a filtered stream list, by using Java 8 Stream's API and the given predicate. + * + * @param streamList the stream list to filter + * @param streamListPredicate the predicate which will be used to filter streams + * @param the item type's class that extends {@link Stream} + * @return a new stream list filtered using the given predicate + */ + private static List getFilteredStreamList( + final List streamList, + final Predicate streamListPredicate) { + if (streamList == null) { + return Collections.emptyList(); + } + + return streamList.stream() + .filter(streamListPredicate) + .collect(Collectors.toList()); + } + private static String computeDefaultResolution(final Context context, final int key, final int value) { final SharedPreferences preferences @@ -167,7 +244,7 @@ public final class ListHelper { static int getDefaultResolutionIndex(final String defaultResolution, final String bestResolutionKey, final MediaFormat defaultFormat, - final List videoStreams) { + @Nullable final List videoStreams) { if (videoStreams == null || videoStreams.isEmpty()) { return -1; } @@ -192,56 +269,57 @@ 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() + // Replace any frame rate with nothing + .replaceAll("p\\d+$", "p"))) + .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 +335,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 +357,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 +373,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); } /** @@ -352,8 +435,9 @@ public final class ListHelper { * @param videoStreams the available video streams * @return the index of the preferred video stream */ - static int getVideoStreamIndex(final String targetResolution, final MediaFormat targetFormat, - final List videoStreams) { + static int getVideoStreamIndex(@NonNull final String targetResolution, + final MediaFormat targetFormat, + @NonNull final List videoStreams) { int fullMatchIndex = -1; int fullMatchNoRefreshIndex = -1; int resMatchOnlyIndex = -1; @@ -414,7 +498,7 @@ public final class ListHelper { * @param videoStreams the list of video streams to check * @return the index of the preferred video stream */ - private static int getDefaultResolutionWithDefaultFormat(final Context context, + private static int getDefaultResolutionWithDefaultFormat(@NonNull final Context context, final String defaultResolution, final List videoStreams) { final MediaFormat defaultFormat = getDefaultFormat(context, @@ -423,7 +507,7 @@ public final class ListHelper { context.getString(R.string.best_resolution_key), defaultFormat, videoStreams); } - private static MediaFormat getDefaultFormat(final Context context, + private static MediaFormat getDefaultFormat(@NonNull final Context context, @StringRes final int defaultFormatKey, @StringRes final int defaultFormatValueKey) { final SharedPreferences preferences @@ -443,8 +527,8 @@ public final class ListHelper { return defaultMediaFormat; } - private static MediaFormat getMediaFormatFromKey(final Context context, - final String formatKey) { + private static MediaFormat getMediaFormatFromKey(@NonNull final Context context, + @NonNull final String formatKey) { MediaFormat format = null; if (formatKey.equals(context.getString(R.string.video_webm_key))) { format = MediaFormat.WEBM; @@ -482,12 +566,20 @@ public final class ListHelper { - formatRanking.indexOf(streamB.getFormat()); } - private static int compareVideoStreamResolution(final String r1, final String r2) { - final int res1 = Integer.parseInt(r1.replaceAll("0p\\d+$", "1") - .replaceAll("[^\\d.]", "")); - final int res2 = Integer.parseInt(r2.replaceAll("0p\\d+$", "1") - .replaceAll("[^\\d.]", "")); - return res1 - res2; + private static int compareVideoStreamResolution(@NonNull final String r1, + @NonNull final String r2) { + try { + final int res1 = Integer.parseInt(r1.replaceAll("0p\\d+$", "1") + .replaceAll("[^\\d.]", "")); + final int res2 = Integer.parseInt(r2.replaceAll("0p\\d+$", "1") + .replaceAll("[^\\d.]", "")); + return res1 - res2; + } catch (final NumberFormatException e) { + // Consider the first one greater because we don't know if the two streams are + // different or not (a NumberFormatException was thrown so we don't know the resolution + // of one stream or of all streams) + return 1; + } } // Compares the quality of two video streams. @@ -522,7 +614,7 @@ public final class ListHelper { * @param context App context * @return maximum resolution allowed or null if there is no maximum */ - private static String getResolutionLimit(final Context context) { + private static String getResolutionLimit(@NonNull final Context context) { String resolutionLimit = null; if (isMeteredNetwork(context)) { final SharedPreferences preferences @@ -541,7 +633,7 @@ public final class ListHelper { * @param context App context * @return {@code true} if connected to a metered network */ - public static boolean isMeteredNetwork(final Context context) { + public static boolean isMeteredNetwork(@NonNull final Context context) { final ConnectivityManager manager = ContextCompat.getSystemService(context, ConnectivityManager.class); if (manager == null || manager.getActiveNetworkInfo() == null) { 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..c40b1a430 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; @@ -29,8 +33,10 @@ import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.DeliveryMethod; 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; @@ -55,11 +61,9 @@ import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.settings.SettingsActivity; import org.schabi.newpipe.util.external_communication.ShareUtils; -import java.util.ArrayList; +import java.util.List; -import static org.schabi.newpipe.util.external_communication.ShareUtils.installApp; - -import com.jakewharton.processphoenix.ProcessPhoenix; +import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; public final class NavigationHelper { public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag"; @@ -214,37 +218,98 @@ public final class NavigationHelper { // External Players //////////////////////////////////////////////////////////////////////////*/ - public static void playOnExternalAudioPlayer(final Context context, final StreamInfo info) { - final int index = ListHelper.getDefaultAudioFormat(context, info.getAudioStreams()); - - if (index == -1) { + public static void playOnExternalAudioPlayer(@NonNull final Context context, + @NonNull final StreamInfo info) { + final List audioStreams = info.getAudioStreams(); + if (audioStreams == null || audioStreams.isEmpty()) { Toast.makeText(context, R.string.audio_streams_empty, Toast.LENGTH_SHORT).show(); return; } - final AudioStream audioStream = info.getAudioStreams().get(index); + final List audioStreamsForExternalPlayers = + getUrlAndNonTorrentStreams(audioStreams); + if (audioStreamsForExternalPlayers.isEmpty()) { + Toast.makeText(context, R.string.no_audio_streams_available_for_external_players, + Toast.LENGTH_SHORT).show(); + return; + } + + final int index = ListHelper.getDefaultAudioFormat(context, audioStreamsForExternalPlayers); + final AudioStream audioStream = audioStreamsForExternalPlayers.get(index); + playOnExternalPlayer(context, info.getName(), info.getUploaderName(), audioStream); } - public static void playOnExternalVideoPlayer(final Context context, final StreamInfo info) { - final ArrayList videoStreamsList = new ArrayList<>( - ListHelper.getSortedStreamVideosList(context, info.getVideoStreams(), null, false)); - final int index = ListHelper.getDefaultResolutionIndex(context, videoStreamsList); - - if (index == -1) { + public static void playOnExternalVideoPlayer(final Context context, + @NonNull final StreamInfo info) { + final List videoStreams = info.getVideoStreams(); + if (videoStreams == null || videoStreams.isEmpty()) { Toast.makeText(context, R.string.video_streams_empty, Toast.LENGTH_SHORT).show(); return; } - final VideoStream videoStream = videoStreamsList.get(index); + final List videoStreamsForExternalPlayers = + ListHelper.getSortedStreamVideosList(context, + getUrlAndNonTorrentStreams(videoStreams), null, false, false); + if (videoStreamsForExternalPlayers.isEmpty()) { + Toast.makeText(context, R.string.no_video_streams_available_for_external_players, + Toast.LENGTH_SHORT).show(); + return; + } + + final int index = ListHelper.getDefaultResolutionIndex(context, + videoStreamsForExternalPlayers); + + final VideoStream videoStream = videoStreamsForExternalPlayers.get(index); 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 DeliveryMethod deliveryMethod = stream.getDeliveryMethod(); + final String mimeType; + + if (!stream.isUrl() || deliveryMethod == DeliveryMethod.TORRENT) { + Toast.makeText(context, R.string.selected_stream_external_player_not_supported, + Toast.LENGTH_SHORT).show(); + return; + } + + switch (deliveryMethod) { + case PROGRESSIVE_HTTP: + if (stream.getFormat() == null) { + if (stream instanceof AudioStream) { + mimeType = "audio/*"; + } else if (stream instanceof VideoStream) { + mimeType = "video/*"; + } else { + // This should never be reached, because subtitles are not opened in + // external players + return; + } + } else { + mimeType = stream.getFormat().getMimeType(); + } + break; + case HLS: + mimeType = "application/x-mpegURL"; + break; + case DASH: + mimeType = "application/dash+xml"; + break; + case SS: + mimeType = "application/vnd.ms-sstr+xml"; + break; + default: + // Torrent streams are not exposed to external players + mimeType = ""; + } + final Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); - intent.setDataAndType(Uri.parse(stream.getUrl()), stream.getFormat().getMimeType()); + intent.setDataAndType(Uri.parse(stream.getContent()), mimeType); intent.putExtra(Intent.EXTRA_TITLE, name); intent.putExtra("title", name); intent.putExtra("artist", artist); @@ -253,7 +318,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 +468,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 +670,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..aabc459d0 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,20 @@ 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 androidx.annotation.Nullable; 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 +22,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 @@ -155,6 +160,33 @@ public final class PicassoHelper { }); } + @Nullable + public static Bitmap getImageFromCacheIfPresent(final String imageUrl) { + // URLs in the internal cache finish with \n so we need to add \n to image URLs + return picassoCache.get(imageUrl + "\n"); + } + + 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)) { 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..c07f307f2 --- /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 = + "7F:46:0D:D0:6A:2D:A0:6B:57:B5:2C:ED:73:06:B7:87:43:90:66:A9" + + @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/SecondaryStreamHelper.java b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java index 8c697d327..e7fd2d4a4 100644 --- a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.util; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.AudioStream; @@ -14,7 +15,8 @@ public class SecondaryStreamHelper { private final int position; private final StreamSizeWrapper streams; - public SecondaryStreamHelper(final StreamSizeWrapper streams, final T selectedStream) { + public SecondaryStreamHelper(@NonNull final StreamSizeWrapper streams, + final T selectedStream) { this.streams = streams; this.position = streams.getStreamsList().indexOf(selectedStream); if (this.position < 0) { @@ -29,9 +31,15 @@ public class SecondaryStreamHelper { * @param videoStream desired video ONLY stream * @return selected audio stream or null if a candidate was not found */ + @Nullable public static AudioStream getAudioStreamFor(@NonNull final List audioStreams, @NonNull final VideoStream videoStream) { - switch (videoStream.getFormat()) { + final MediaFormat mediaFormat = videoStream.getFormat(); + if (mediaFormat == null) { + return null; + } + + switch (mediaFormat) { case WEBM: case MPEG_4:// ¿is mpeg-4 DASH? break; @@ -39,7 +47,7 @@ public class SecondaryStreamHelper { return null; } - final boolean m4v = videoStream.getFormat() == MediaFormat.MPEG_4; + final boolean m4v = (mediaFormat == MediaFormat.MPEG_4); for (final AudioStream audio : audioStreams) { if (audio.getFormat() == (m4v ? MediaFormat.M4A : MediaFormat.WEBMA)) { 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/ServiceHelper.java b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java index d41493a7f..b13ae4a97 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java @@ -1,9 +1,13 @@ package org.schabi.newpipe.util; +import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; + import android.content.Context; import android.content.SharedPreferences; import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.preference.PreferenceManager; @@ -18,10 +22,9 @@ import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; +import java.util.Optional; import java.util.concurrent.TimeUnit; -import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; - public final class ServiceHelper { private static final StreamingService DEFAULT_FALLBACK_SERVICE = ServiceList.YouTube; @@ -31,17 +34,17 @@ public final class ServiceHelper { public static int getIcon(final int serviceId) { switch (serviceId) { case 0: - return R.drawable.place_holder_youtube; + return R.drawable.ic_smart_display; case 1: - return R.drawable.place_holder_cloud; + return R.drawable.ic_cloud; case 2: - return R.drawable.place_holder_gadse; + return R.drawable.ic_placeholder_media_ccc; case 3: - return R.drawable.place_holder_peertube; + return R.drawable.ic_placeholder_peertube; case 4: - return R.drawable.place_holder_bandcamp; + return R.drawable.ic_placeholder_bandcamp; default: - return R.drawable.place_holder_circle; + return R.drawable.ic_circle; } } @@ -113,18 +116,32 @@ public final class ServiceHelper { } public static int getSelectedServiceId(final Context context) { + return Optional.ofNullable(getSelectedService(context)) + .orElse(DEFAULT_FALLBACK_SERVICE) + .getServiceId(); + } + + @Nullable + public static StreamingService getSelectedService(final Context context) { final String serviceName = PreferenceManager.getDefaultSharedPreferences(context) .getString(context.getString(R.string.current_service_key), context.getString(R.string.default_service_value)); - int serviceId; try { - serviceId = NewPipe.getService(serviceName).getServiceId(); + return NewPipe.getService(serviceName); } catch (final ExtractionException e) { - serviceId = DEFAULT_FALLBACK_SERVICE.getServiceId(); + return null; } + } - return serviceId; + @NonNull + public static String getNameOfServiceById(final int serviceId) { + return ServiceList.all().stream() + .filter(s -> s.getServiceId() == serviceId) + .findFirst() + .map(StreamingService::getServiceInfo) + .map(StreamingService.ServiceInfo::getName) + .orElse(""); } public static void setSelectedServiceId(final Context context, final int serviceId) { @@ -138,16 +155,6 @@ public final class ServiceHelper { setSelectedServicePreferences(context, serviceName); } - public static void setSelectedServiceId(final Context context, final String serviceName) { - final int serviceId = NewPipe.getIdOfService(serviceName); - if (serviceId == -1) { - setSelectedServicePreferences(context, - DEFAULT_FALLBACK_SERVICE.getServiceInfo().getName()); - } else { - setSelectedServicePreferences(context, serviceName); - } - } - private static void setSelectedServicePreferences(final Context context, final String serviceName) { PreferenceManager.getDefaultSharedPreferences(context).edit(). 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..0c5f418b2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java @@ -0,0 +1,127 @@ +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 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 ((StreamTypeUtil.isLiveStream(item.getStreamType()) || 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/SponsorBlockUtils.java b/app/src/main/java/org/schabi/newpipe/util/SponsorBlockUtils.java index a6015597a..baad03fb8 100644 --- a/app/src/main/java/org/schabi/newpipe/util/SponsorBlockUtils.java +++ b/app/src/main/java/org/schabi/newpipe/util/SponsorBlockUtils.java @@ -73,6 +73,8 @@ public final class SponsorBlockUtils { .getString(R.string.sponsor_block_category_non_music_key), false); final boolean includePreviewCategory = prefs.getBoolean(context .getString(R.string.sponsor_block_category_preview_key), false); + final boolean includeFillerCategory = prefs.getBoolean(context + .getString(R.string.sponsor_block_category_filler_key), false); final ArrayList categoryParamList = new ArrayList<>(); @@ -96,6 +98,9 @@ public final class SponsorBlockUtils { } if (includePreviewCategory) { categoryParamList.add("preview"); + + if (includeFillerCategory) { + categoryParamList.add("filler"); } if (categoryParamList.size() == 0) { @@ -281,6 +286,14 @@ public final class SponsorBlockUtils { return colorStr == null ? context.getResources().getColor(R.color.preview_segment) : Color.parseColor(colorStr); + case "filler": + key = context.getString(R.string.sponsor_block_category_filler_key); + if (prefs.getBoolean(key, false)) { + key = context.getString(R.string.sponsor_block_category_filler_color_key); + colorStr = prefs.getString(key, null); + return colorStr == null + ? context.getResources().getColor(R.color.filler_segment) + : Color.parseColor(colorStr); } break; } 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..4b5e675c9 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java @@ -10,8 +10,11 @@ import android.widget.ImageView; import android.widget.Spinner; import android.widget.TextView; +import androidx.annotation.NonNull; + 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 +44,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) { @@ -81,7 +89,8 @@ public class StreamItemAdapter extends BaseA } @Override - public View getDropDownView(final int position, final View convertView, + public View getDropDownView(final int position, + final View convertView, final ViewGroup parent) { return getCustomView(position, convertView, parent, true); } @@ -92,7 +101,10 @@ public class StreamItemAdapter extends BaseA convertView, parent, false); } - private View getCustomView(final int position, final View view, final ViewGroup parent, + @NonNull + private View getCustomView(final int position, + final View view, + final ViewGroup parent, final boolean isDropdownItem) { View convertView = view; if (convertView == null) { @@ -106,6 +118,7 @@ public class StreamItemAdapter extends BaseA final TextView sizeView = convertView.findViewById(R.id.stream_size); final T stream = getItem(position); + final MediaFormat mediaFormat = stream.getFormat(); int woSoundIconVisibility = View.GONE; String qualityString; @@ -114,34 +127,47 @@ 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; } } } else if (stream instanceof AudioStream) { final AudioStream audioStream = ((AudioStream) stream); - qualityString = audioStream.getAverageBitrate() > 0 - ? audioStream.getAverageBitrate() + "kbps" - : audioStream.getFormat().getName(); + if (audioStream.getAverageBitrate() > 0) { + qualityString = audioStream.getAverageBitrate() + "kbps"; + } else if (mediaFormat != null) { + qualityString = mediaFormat.getName(); + } else { + qualityString = context.getString(R.string.unknown_quality); + } } else if (stream instanceof SubtitlesStream) { qualityString = ((SubtitlesStream) stream).getDisplayLanguageName(); if (((SubtitlesStream) stream).isAutoGenerated()) { qualityString += " (" + context.getString(R.string.caption_auto_generated) + ")"; } } else { - qualityString = stream.getFormat().getSuffix(); + if (mediaFormat == null) { + qualityString = context.getString(R.string.unknown_quality); + } else { + qualityString = mediaFormat.getSuffix(); + } } 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 - = secondary.getSizeInBytes() + streamsWrapper.getSizeInBytes(position); + final long size = secondary.getSizeInBytes() + + streamsWrapper.getSizeInBytes(position); sizeView.setText(Utility.formatBytes(size)); } else { sizeView.setText(streamsWrapper.getFormattedSize(position)); @@ -154,14 +180,13 @@ public class StreamItemAdapter extends BaseA if (stream instanceof SubtitlesStream) { formatNameView.setText(((SubtitlesStream) stream).getLanguageTag()); } else { - switch (stream.getFormat()) { - case WEBMA_OPUS: - // noinspection AndroidLintSetTextI18n - formatNameView.setText("opus"); - break; - default: - formatNameView.setText(stream.getFormat().getName()); - break; + if (mediaFormat == null) { + formatNameView.setText(context.getString(R.string.unknown_format)); + } else if (mediaFormat == MediaFormat.WEBMA_OPUS) { + // noinspection AndroidLintSetTextI18n + formatNameView.setText("opus"); + } else { + formatNameView.setText(mediaFormat.getName()); } } @@ -171,6 +196,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. * @@ -201,6 +252,7 @@ public class StreamItemAdapter extends BaseA * @param streamsWrapper the wrapper * @return a {@link Single} that returns a boolean indicating if any elements were changed */ + @NonNull public static Single fetchSizeForWrapper( final StreamSizeWrapper streamsWrapper) { final Callable fetchAndSet = () -> { @@ -211,7 +263,7 @@ public class StreamItemAdapter extends BaseA } final long contentLength = DownloaderImpl.getInstance().getContentLength( - stream.getUrl()); + stream.getContent()); streamsWrapper.setSize(stream, contentLength); hasChanged = true; } diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java b/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java index 87b3eed4f..0cc0ecf1f 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java @@ -3,7 +3,7 @@ package org.schabi.newpipe.util; import org.schabi.newpipe.extractor.stream.StreamType; /** - * Utility class for {@link org.schabi.newpipe.extractor.stream.StreamType}. + * Utility class for {@link StreamType}. */ public final class StreamTypeUtil { private StreamTypeUtil() { @@ -11,11 +11,37 @@ public final class StreamTypeUtil { } /** - * Checks if the streamType is a livestream. + * Check if the {@link StreamType} of a stream is a livestream. * - * @param streamType - * @return true when the streamType is a - * {@link StreamType#LIVE_STREAM} or {@link StreamType#AUDIO_LIVE_STREAM} + * @param streamType the stream type of the stream + * @return whether the stream type is {@link StreamType#AUDIO_STREAM}, + * {@link StreamType#AUDIO_LIVE_STREAM} or {@link StreamType#POST_LIVE_AUDIO_STREAM} + */ + public static boolean isAudio(final StreamType streamType) { + return streamType == StreamType.AUDIO_STREAM + || streamType == StreamType.AUDIO_LIVE_STREAM + || streamType == StreamType.POST_LIVE_AUDIO_STREAM; + } + + /** + * Check if the {@link StreamType} of a stream is a livestream. + * + * @param streamType the stream type of the stream + * @return whether the stream type is {@link StreamType#VIDEO_STREAM}, + * {@link StreamType#LIVE_STREAM} or {@link StreamType#POST_LIVE_STREAM} + */ + public static boolean isVideo(final StreamType streamType) { + return streamType == StreamType.VIDEO_STREAM + || streamType == StreamType.LIVE_STREAM + || streamType == StreamType.POST_LIVE_STREAM; + } + + /** + * Check if the {@link StreamType} of a stream is a livestream. + * + * @param streamType the stream type of the stream + * @return whether the stream type is {@link StreamType#LIVE_STREAM} or + * {@link StreamType#AUDIO_LIVE_STREAM} */ public static boolean isLiveStream(final StreamType streamType) { return streamType == StreamType.LIVE_STREAM diff --git a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java index 7c47d387f..b8e3a86ed 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java @@ -23,14 +23,17 @@ import android.app.Activity; import android.content.Context; import android.content.res.Configuration; import android.content.res.Resources; +import android.graphics.drawable.Drawable; import android.util.TypedValue; import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StyleRes; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatDelegate; +import androidx.appcompat.content.res.AppCompatResources; import androidx.core.content.ContextCompat; import androidx.preference.PreferenceManager; @@ -227,6 +230,20 @@ public final class ThemeHelper { return value.data; } + /** + * Resolves a {@link Drawable} by it's id. + * + * @param context Context + * @param attrResId Resource id + * @return the {@link Drawable} + */ + public static Drawable resolveDrawable(@NonNull final Context context, + @AttrRes final int attrResId) { + final TypedValue typedValue = new TypedValue(); + context.getTheme().resolveAttribute(attrResId, typedValue, true); + return AppCompatResources.getDrawable(context, typedValue.resourceId); + } + private static String getSelectedThemeKey(final Context context) { final String themeKey = context.getString(R.string.theme_key); final String defaultTheme = context.getResources().getString(R.string.default_theme_value); 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/util/external_communication/ShareUtils.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java index c4f1675cf..8324146fe 100644 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.util.external_communication; +import static org.schabi.newpipe.MainActivity.DEBUG; + import android.content.ActivityNotFoundException; import android.content.ClipData; import android.content.ClipboardManager; @@ -7,17 +9,28 @@ import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; +import android.graphics.Bitmap; import android.net.Uri; import android.os.Build; import android.text.TextUtils; +import android.util.Log; import android.widget.Toast; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; +import androidx.core.content.FileProvider; +import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.R; +import org.schabi.newpipe.util.PicassoHelper; + +import java.io.File; +import java.io.FileOutputStream; public final class ShareUtils { + private static final String TAG = ShareUtils.class.getSimpleName(); + private ShareUtils() { } @@ -231,9 +244,11 @@ public final class ShareUtils { /** * Open the android share sheet to share a content. * + *

* For Android 10+ users, a content preview is shown, which includes the title of the shared - * content. - * Support sharing the image of the content needs to done, if possible. + * content and an image preview the content, if its URL is not null or empty and its + * corresponding image is in the image cache. + *

* * @param context the context to use * @param title the title of the content @@ -252,13 +267,20 @@ public final class ShareUtils { shareIntent.putExtra(Intent.EXTRA_SUBJECT, title); } - /* TODO: add the image of the content to Android share sheet with setClipData after - generating a content URI of this image, then use ClipData.newUri(the content resolver, - null, the content URI) and set the ClipData to the share intent with - shareIntent.setClipData(generated ClipData). - if (!imagePreviewUrl.isEmpty()) { - //shareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - }*/ + // Content preview in the share sheet has been added in Android 10, so it's not needed to + // set a content preview which will be never displayed + // See https://developer.android.com/training/sharing/send#adding-rich-content-previews + // If loading of images has been disabled, don't try to generate a content preview + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + && !TextUtils.isEmpty(imagePreviewUrl) + && PicassoHelper.getShouldLoadImages()) { + + final ClipData clipData = generateClipDataForImagePreview(context, imagePreviewUrl); + if (clipData != null) { + shareIntent.setClipData(clipData); + shareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + } openAppChooser(context, shareIntent, false); } @@ -266,11 +288,11 @@ public final class ShareUtils { /** * Open the android share sheet to share a content. * - * For Android 10+ users, a content preview is shown, which includes the title of the shared - * content. *

* This calls {@link #shareText(Context, String, String, String)} with an empty string for the - * imagePreviewUrl parameter. + * {@code imagePreviewUrl} parameter. This method should be used when the shared content has no + * preview thumbnail. + *

* * @param context the context to use * @param title the title of the content @@ -301,4 +323,81 @@ public final class ShareUtils { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); } + + /** + * Generate a {@link ClipData} with the image of the content shared, if it's in the app cache. + * + *

+ * In order not to worry about network issues (timeouts, DNS issues, low connection speed, ...) + * when sharing a content, only images in the {@link com.squareup.picasso.LruCache LruCache} + * used by the Picasso library inside {@link PicassoHelper} are used as preview images. If the + * thumbnail image is not in the cache, no {@link ClipData} will be generated and {@code null} + * will be returned. + *

+ * + *

+ * In order to display the image in the content preview of the Android share sheet, an URI of + * the content, accessible and readable by other apps has to be generated, so a new file inside + * the application cache will be generated, named {@code android_share_sheet_image_preview.jpg} + * (if a file under this name already exists, it will be overwritten). The thumbnail will be + * compressed in JPEG format, with a {@code 90} compression level. + *

+ * + *

+ * Note that if an exception occurs when generating the {@link ClipData}, {@code null} is + * returned. + *

+ * + *

+ * This method will call {@link PicassoHelper#getImageFromCacheIfPresent(String)} to get the + * thumbnail of the content in the {@link com.squareup.picasso.LruCache LruCache} used by + * the Picasso library inside {@link PicassoHelper}. + *

+ * + *

+ * Using the result of this method when sharing has only an effect on the system share sheet (if + * OEMs didn't change Android system standard behavior) on Android API 29 and higher. + *

+ * + * @param context the context to use + * @param thumbnailUrl the URL of the content thumbnail + * @return a {@link ClipData} of the content thumbnail, or {@code null} + */ + @Nullable + private static ClipData generateClipDataForImagePreview( + @NonNull final Context context, + @NonNull final String thumbnailUrl) { + try { + final Bitmap bitmap = PicassoHelper.getImageFromCacheIfPresent(thumbnailUrl); + if (bitmap == null) { + return null; + } + + // Save the image in memory to the application's cache because we need a URI to the + // image to generate a ClipData which will show the share sheet, and so an image file + final Context applicationContext = context.getApplicationContext(); + final String appFolder = applicationContext.getCacheDir().getAbsolutePath(); + final File thumbnailPreviewFile = new File(appFolder + + "/android_share_sheet_image_preview.jpg"); + + // Any existing file will be overwritten with FileOutputStream + final FileOutputStream fileOutputStream = new FileOutputStream(thumbnailPreviewFile); + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, fileOutputStream); + fileOutputStream.close(); + + final ClipData clipData = ClipData.newUri(applicationContext.getContentResolver(), "", + FileProvider.getUriForFile(applicationContext, + BuildConfig.APPLICATION_ID + ".provider", + thumbnailPreviewFile)); + + if (DEBUG) { + Log.d(TAG, "ClipData successfully generated for Android share sheet: " + clipData); + } + return clipData; + + } catch (final Exception e) { + Log.w(TAG, "Error when setting preview image for share sheet", e); + return null; + } + } } 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/DownloadMissionRecover.java b/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java index 5b2858aa2..e001c6f3f 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java @@ -6,6 +6,7 @@ import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.DeliveryMethod; import org.schabi.newpipe.extractor.stream.StreamExtractor; import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; @@ -131,31 +132,38 @@ public class DownloadMissionRecover extends Thread { switch (mRecovery.getKind()) { case 'a': - for (AudioStream audio : mExtractor.getAudioStreams()) { - if (audio.average_bitrate == mRecovery.getDesiredBitrate() && audio.getFormat() == mRecovery.getFormat()) { - url = audio.getUrl(); + for (final AudioStream audio : mExtractor.getAudioStreams()) { + if (audio.getAverageBitrate() == mRecovery.getDesiredBitrate() + && audio.getFormat() == mRecovery.getFormat() + && audio.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP) { + url = audio.getContent(); break; } } break; case 'v': - List videoStreams; + final List videoStreams; if (mRecovery.isDesired2()) videoStreams = mExtractor.getVideoOnlyStreams(); else videoStreams = mExtractor.getVideoStreams(); - for (VideoStream video : videoStreams) { - if (video.resolution.equals(mRecovery.getDesired()) && video.getFormat() == mRecovery.getFormat()) { - url = video.getUrl(); + for (final VideoStream video : videoStreams) { + if (video.getResolution().equals(mRecovery.getDesired()) + && video.getFormat() == mRecovery.getFormat() + && video.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP) { + url = video.getContent(); break; } } break; case 's': - for (SubtitlesStream subtitles : mExtractor.getSubtitles(mRecovery.getFormat())) { + for (final SubtitlesStream subtitles : mExtractor.getSubtitles(mRecovery + .getFormat())) { String tag = subtitles.getLanguageTag(); - if (tag.equals(mRecovery.getDesired()) && subtitles.isAutoGenerated() == mRecovery.isDesired2()) { - url = subtitles.getUrl(); + if (tag.equals(mRecovery.getDesired()) + && subtitles.isAutoGenerated() == mRecovery.isDesired2() + && subtitles.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP) { + url = subtitles.getContent(); break; } } 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 403eee0c7..c2f9dc9b2 100644 --- a/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.kt +++ b/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.kt @@ -11,17 +11,17 @@ import java.io.Serializable @Parcelize class MissionRecoveryInfo( - var format: MediaFormat, + var format: MediaFormat?, var desired: String? = null, var isDesired2: Boolean = false, var desiredBitrate: Int = 0, var kind: Char = Char.MIN_VALUE, var validateCondition: String? = null ) : Serializable, Parcelable { - constructor(stream: Stream) : this(format = stream.getFormat()!!) { + constructor(stream: Stream) : this(format = stream.format) { when (stream) { is AudioStream -> { - desiredBitrate = stream.average_bitrate + desiredBitrate = stream.getAverageBitrate() isDesired2 = false kind = 'a' } @@ -62,7 +62,7 @@ class MissionRecoveryInfo( } } str.append(" format=") - .append(format.getName()) + .append(format?.getName()) .append(' ') .append(info) .append('}') diff --git a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java index abbead5d6..aac88ea56 100644 --- a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java +++ b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java @@ -30,7 +30,6 @@ import androidx.appcompat.app.AlertDialog; import androidx.core.app.NotificationCompat; import androidx.core.content.ContextCompat; import androidx.core.content.FileProvider; -import androidx.core.view.ViewCompat; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.RecyclerView; @@ -900,7 +899,7 @@ public class MissionAdapter extends Adapter implements Handler.Callb super(view); progress = new ProgressDrawable(); - ViewCompat.setBackground(itemView.findViewById(R.id.item_bkg), progress); + itemView.findViewById(R.id.item_bkg).setBackground(progress); status = itemView.findViewById(R.id.item_status); name = itemView.findViewById(R.id.item_name); 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_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-nodpi/place_holder_bandcamp.png b/app/src/main/res/drawable-nodpi/place_holder_bandcamp.png deleted file mode 100644 index 13c44b649..000000000 Binary files a/app/src/main/res/drawable-nodpi/place_holder_bandcamp.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/place_holder_circle.png b/app/src/main/res/drawable-nodpi/place_holder_circle.png deleted file mode 100644 index 630d0454e..000000000 Binary files a/app/src/main/res/drawable-nodpi/place_holder_circle.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/place_holder_cloud.png b/app/src/main/res/drawable-nodpi/place_holder_cloud.png deleted file mode 100644 index c4ba2a6f4..000000000 Binary files a/app/src/main/res/drawable-nodpi/place_holder_cloud.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/place_holder_gadse.png b/app/src/main/res/drawable-nodpi/place_holder_gadse.png deleted file mode 100644 index 9b479ed4f..000000000 Binary files a/app/src/main/res/drawable-nodpi/place_holder_gadse.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/place_holder_peertube.png b/app/src/main/res/drawable-nodpi/place_holder_peertube.png deleted file mode 100644 index 81dfdb8cc..000000000 Binary files a/app/src/main/res/drawable-nodpi/place_holder_peertube.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/place_holder_youtube.png b/app/src/main/res/drawable-nodpi/place_holder_youtube.png deleted file mode 100644 index d147c6643..000000000 Binary files a/app/src/main/res/drawable-nodpi/place_holder_youtube.png and /dev/null differ 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_circle.xml b/app/src/main/res/drawable/ic_circle.xml new file mode 100644 index 000000000..dc0a218b8 --- /dev/null +++ b/app/src/main/res/drawable/ic_circle.xml @@ -0,0 +1,10 @@ + + + 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-night/ic_cloud_download.xml b/app/src/main/res/drawable/ic_cloud.xml similarity index 54% rename from app/src/main/res/drawable-night/ic_cloud_download.xml rename to app/src/main/res/drawable/ic_cloud.xml index 67e870456..15a682b76 100644 --- a/app/src/main/res/drawable-night/ic_cloud_download.xml +++ b/app/src/main/res/drawable/ic_cloud.xml @@ -1,10 +1,10 @@ + android:width="24dp" + android:viewportWidth="24" + android:viewportHeight="24" + android:tint="@color/defaultIconTint"> + android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96z" + android:fillColor="#FF000000" /> 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 @@ + + diff --git a/app/src/main/res/drawable/ic_placeholder_media_ccc.xml b/app/src/main/res/drawable/ic_placeholder_media_ccc.xml new file mode 100644 index 000000000..cdc743cb2 --- /dev/null +++ b/app/src/main/res/drawable/ic_placeholder_media_ccc.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_placeholder_peertube.xml b/app/src/main/res/drawable/ic_placeholder_peertube.xml new file mode 100644 index 000000000..263d92d70 --- /dev/null +++ b/app/src/main/res/drawable/ic_placeholder_peertube.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_play_arrow.xml b/app/src/main/res/drawable/ic_play_arrow.xml index dbe3ec664..a70a4ddbb 100644 --- a/app/src/main/res/drawable/ic_play_arrow.xml +++ b/app/src/main/res/drawable/ic_play_arrow.xml @@ -1,8 +1,9 @@ + 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_smart_display.xml b/app/src/main/res/drawable/ic_smart_display.xml new file mode 100644 index 000000000..d666a3b37 --- /dev/null +++ b/app/src/main/res/drawable/ic_smart_display.xml @@ -0,0 +1,10 @@ + + + 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 b9566051e..000000000 --- a/app/src/main/res/layout-large-land/player.xml +++ /dev/null @@ -1,771 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -