mirror of
				https://github.com/TeamNewPipe/NewPipe
				synced 2025-10-31 15:23:00 +00:00 
			
		
		
		
	Merge pull request #8231 from TeamNewPipe/release/0.23.0
Release 0.23.0 (986)
This commit is contained in:
		
							
								
								
									
										2
									
								
								.github/CONTRIBUTING.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/CONTRIBUTING.md
									
									
									
									
										vendored
									
									
								
							| @@ -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. | ||||
|   | ||||
							
								
								
									
										19
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										19
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
|   | ||||
							
								
								
									
										6
									
								
								.github/workflows/image-minimizer.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/image-minimizer.yml
									
									
									
									
										vendored
									
									
								
							| @@ -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: | | ||||
|   | ||||
							
								
								
									
										8
									
								
								LICENSE
									
									
									
									
									
								
							
							
						
						
									
										8
									
								
								LICENSE
									
									
									
									
									
								
							| @@ -1,7 +1,7 @@ | ||||
|                     GNU GENERAL PUBLIC LICENSE | ||||
|                        Version 3, 29 June 2007 | ||||
|  | ||||
|  Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> | ||||
|  Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> | ||||
|  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 <http://www.gnu.org/licenses/>. | ||||
|     along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||
|  | ||||
| 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 | ||||
| <http://www.gnu.org/licenses/>. | ||||
| <https://www.gnu.org/licenses/>. | ||||
|  | ||||
|   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 | ||||
| <http://www.gnu.org/philosophy/why-not-lgpl.html>. | ||||
| <https://www.gnu.org/licenses/why-not-lgpl.html>. | ||||
|   | ||||
| @@ -9,15 +9,15 @@ plugins { | ||||
|  | ||||
| android { | ||||
|     compileSdk 31 | ||||
|     buildToolsVersion '30.0.3' | ||||
|     buildToolsVersion '31.0.0' | ||||
|  | ||||
|     defaultConfig { | ||||
|         applicationId "org.schabi.newpipe" | ||||
|         resValue "string", "app_name", "NewPipe" | ||||
|         minSdk 19 | ||||
|         targetSdk 29 | ||||
|         versionCode 985 | ||||
|         versionName "0.22.2" | ||||
|         versionCode 986 | ||||
|         versionName "0.23.0" | ||||
|  | ||||
|         multiDexEnabled true | ||||
|  | ||||
| @@ -98,13 +98,14 @@ android { | ||||
| } | ||||
|  | ||||
| ext { | ||||
|     checkstyleVersion = '9.2.1' | ||||
|     checkstyleVersion = '10.0' | ||||
|  | ||||
|     androidxLifecycleVersion = '2.3.1' | ||||
|     androidxRoomVersion = '2.3.0' | ||||
|     androidxRoomVersion = '2.4.2' | ||||
|     androidxWorkVersion = '2.7.1' | ||||
|  | ||||
|     icepickVersion = '3.2.0' | ||||
|     exoPlayerVersion = '2.14.2' | ||||
|     exoPlayerVersion = '2.17.1' | ||||
|     googleAutoServiceVersion = '1.0.1' | ||||
|     groupieVersion = '2.10.0' | ||||
|     markwonVersion = '4.6.2' | ||||
| @@ -121,7 +122,7 @@ configurations { | ||||
| } | ||||
|  | ||||
| checkstyle { | ||||
|     getConfigDirectory().set(rootProject.file(".")) | ||||
|     getConfigDirectory().set(rootProject.file("checkstyle")) | ||||
|     ignoreFailures false | ||||
|     showViolations true | ||||
|     toolVersion = checkstyleVersion | ||||
| @@ -189,11 +190,11 @@ dependencies { | ||||
|     // name and the commit hash with the commit hash of the (pushed) commit you want to test | ||||
|     // This works thanks to JitPack: https://jitpack.io/ | ||||
|     implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751' | ||||
|     implementation 'com.github.TeamNewPipe:NewPipeExtractor:b77c72fb8826c3ffca0be5f96b066cca0a07b1c9' | ||||
|     implementation 'com.github.TeamNewPipe:NewPipeExtractor:ac1c22d81c65b7b0c5427f4e1989f5256d617f32' | ||||
|  | ||||
| /** Checkstyle **/ | ||||
|     checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}" | ||||
|     ktlint 'com.pinterest:ktlint:0.43.2' | ||||
|     ktlint 'com.pinterest:ktlint:0.44.0' | ||||
|  | ||||
| /** Kotlin **/ | ||||
|     implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}" | ||||
| @@ -201,16 +202,16 @@ dependencies { | ||||
| /** AndroidX **/ | ||||
|     implementation 'androidx.appcompat:appcompat:1.3.1' | ||||
|     implementation 'androidx.cardview:cardview:1.0.0' | ||||
|     implementation 'androidx.constraintlayout:constraintlayout:2.1.2' | ||||
|     implementation 'androidx.constraintlayout:constraintlayout:2.1.3' | ||||
|     implementation 'androidx.core:core-ktx:1.6.0' | ||||
|     implementation 'androidx.documentfile:documentfile:1.0.1' | ||||
|     implementation 'androidx.fragment:fragment-ktx:1.3.6' | ||||
|     implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}" | ||||
|     implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}" | ||||
|     implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0' | ||||
|     implementation 'androidx.media:media:1.4.3' | ||||
|     implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' | ||||
|     implementation 'androidx.media:media:1.5.0' | ||||
|     implementation 'androidx.multidex:multidex:2.0.1' | ||||
|     implementation 'androidx.preference:preference:1.1.1' | ||||
|     implementation 'androidx.preference:preference:1.2.0' | ||||
|     implementation 'androidx.recyclerview:recyclerview:1.2.1' | ||||
|     implementation "androidx.room:room-runtime:${androidxRoomVersion}" | ||||
|     implementation "androidx.room:room-rxjava3:${androidxRoomVersion}" | ||||
| @@ -220,7 +221,10 @@ dependencies { | ||||
|     // https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01 | ||||
|     implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' | ||||
|     implementation 'androidx.webkit:webkit:1.4.0' | ||||
|     implementation 'com.google.android.material:material:1.4.0' | ||||
|     implementation 'com.google.android.material:material:1.5.0' | ||||
|     implementation "androidx.work:work-runtime:${androidxWorkVersion}" | ||||
|     implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}" | ||||
|     implementation "androidx.work:work-rxjava3:${androidxWorkVersion}" | ||||
|  | ||||
| /** Third-party libraries **/ | ||||
|     // Instance state boilerplate elimination | ||||
| @@ -246,8 +250,6 @@ dependencies { | ||||
|     implementation "com.github.lisawray.groupie:groupie:${groupieVersion}" | ||||
|     implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}" | ||||
|  | ||||
|     // Circular ImageView | ||||
|     implementation "de.hdodenhof:circleimageview:3.1.0" | ||||
|     // Image loading | ||||
|     //noinspection GradleDependency --> 2.8 is the last version, not 2.71828! | ||||
|     implementation "com.squareup.picasso:picasso:2.8" | ||||
|   | ||||
							
								
								
									
										3
									
								
								app/proguard-rules.pro
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								app/proguard-rules.pro
									
									
									
									
										vendored
									
									
								
							| @@ -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.** { *; } | ||||
|   | ||||
							
								
								
									
										719
									
								
								app/schemas/org.schabi.newpipe.database.AppDatabase/5.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										719
									
								
								app/schemas/org.schabi.newpipe.database.AppDatabase/5.json
									
									
									
									
									
										Normal file
									
								
							| @@ -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')" | ||||
|     ] | ||||
|   } | ||||
| } | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,188 @@ | ||||
| package org.schabi.newpipe.util | ||||
|  | ||||
| import android.content.Context | ||||
| import android.util.SparseArray | ||||
| import android.view.View | ||||
| import android.view.View.GONE | ||||
| import android.view.View.INVISIBLE | ||||
| import android.view.View.VISIBLE | ||||
| import android.widget.Spinner | ||||
| import androidx.test.core.app.ApplicationProvider | ||||
| import androidx.test.ext.junit.runners.AndroidJUnit4 | ||||
| import androidx.test.filters.MediumTest | ||||
| import androidx.test.internal.runner.junit4.statement.UiThreadStatement | ||||
| import org.junit.Assert | ||||
| import org.junit.Before | ||||
| import org.junit.Test | ||||
| import org.junit.runner.RunWith | ||||
| import org.schabi.newpipe.R | ||||
| import org.schabi.newpipe.extractor.MediaFormat | ||||
| import org.schabi.newpipe.extractor.stream.AudioStream | ||||
| import org.schabi.newpipe.extractor.stream.Stream | ||||
| import org.schabi.newpipe.extractor.stream.SubtitlesStream | ||||
| import org.schabi.newpipe.extractor.stream.VideoStream | ||||
|  | ||||
| @MediumTest | ||||
| @RunWith(AndroidJUnit4::class) | ||||
| class StreamItemAdapterTest { | ||||
|     private lateinit var context: Context | ||||
|     private lateinit var spinner: Spinner | ||||
|  | ||||
|     @Before | ||||
|     fun setUp() { | ||||
|         context = ApplicationProvider.getApplicationContext() | ||||
|         UiThreadStatement.runOnUiThread { | ||||
|             spinner = Spinner(context) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Test | ||||
|     fun videoStreams_noSecondaryStream() { | ||||
|         val adapter = StreamItemAdapter<VideoStream, AudioStream>( | ||||
|             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<SubtitlesStream, Stream>( | ||||
|             context, | ||||
|             StreamItemAdapter.StreamSizeWrapper( | ||||
|                 (0 until 5).map { | ||||
|                     SubtitlesStream(MediaFormat.SRT, "pt-BR", "https://example.com", false) | ||||
|                 }, | ||||
|                 context | ||||
|             ), | ||||
|             null | ||||
|         ) | ||||
|         spinner.adapter = adapter | ||||
|         for (i in 0 until spinner.count) { | ||||
|             assertIconVisibility(spinner, i, GONE, GONE) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Test | ||||
|     fun audioStreams_noIcon() { | ||||
|         val adapter = StreamItemAdapter<AudioStream, Stream>( | ||||
|             context, | ||||
|             StreamItemAdapter.StreamSizeWrapper( | ||||
|                 (0 until 5).map { AudioStream("https://example.com/$it", MediaFormat.OPUS, 192) }, | ||||
|                 context | ||||
|             ), | ||||
|             null | ||||
|         ) | ||||
|         spinner.adapter = adapter | ||||
|         for (i in 0 until spinner.count) { | ||||
|             assertIconVisibility(spinner, i, GONE, GONE) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return a list of video streams, in which their video only property mirrors the provided | ||||
|      * [videoOnly] vararg. | ||||
|      */ | ||||
|     private fun getVideoStreams(vararg videoOnly: Boolean) = | ||||
|         StreamItemAdapter.StreamSizeWrapper( | ||||
|             videoOnly.map { | ||||
|                 VideoStream("https://example.com", MediaFormat.MPEG_4, "720p", it) | ||||
|             }, | ||||
|             context | ||||
|         ) | ||||
|  | ||||
|     /** | ||||
|      * @return a list of audio streams, containing valid and null elements mirroring the provided | ||||
|      * [shouldBeValid] vararg. | ||||
|      */ | ||||
|     private fun getAudioStreams(vararg shouldBeValid: Boolean) = | ||||
|         getSecondaryStreamsFromList( | ||||
|             shouldBeValid.map { | ||||
|                 if (it) AudioStream("https://example.com", MediaFormat.OPUS, 192) | ||||
|                 else null | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|     /** | ||||
|      * Checks whether the item at [position] in the [spinner] has the correct icon visibility when | ||||
|      * it is shown in normal mode (selected) and in dropdown mode (user is choosing one of a list). | ||||
|      */ | ||||
|     private fun assertIconVisibility( | ||||
|         spinner: Spinner, | ||||
|         position: Int, | ||||
|         normalVisibility: Int, | ||||
|         dropDownVisibility: Int | ||||
|     ) { | ||||
|         spinner.setSelection(position) | ||||
|         spinner.adapter.getView(position, null, spinner).run { | ||||
|             Assert.assertEquals( | ||||
|                 "normal visibility (pos=[$position]) is not correct", | ||||
|                 findViewById<View>(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<View>(R.id.wo_sound_icon).visibility, | ||||
|                 dropDownVisibility | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Helper function that builds a secondary stream list. | ||||
|      */ | ||||
|     private fun <T : Stream> getSecondaryStreamsFromList(streams: List<T?>) = | ||||
|         SparseArray<SecondaryStreamHelper<T>?>(streams.size).apply { | ||||
|             streams.forEachIndexed { index, stream -> | ||||
|                 val secondaryStreamHelper: SecondaryStreamHelper<T>? = stream?.let { | ||||
|                     SecondaryStreamHelper( | ||||
|                         StreamItemAdapter.StreamSizeWrapper(streams, context), | ||||
|                         it | ||||
|                     ) | ||||
|                 } | ||||
|                 put(index, secondaryStreamHelper) | ||||
|             } | ||||
|         } | ||||
| } | ||||
| @@ -381,9 +381,6 @@ | ||||
|         <service | ||||
|             android:name=".RouterActivity$FetcherService" | ||||
|             android:exported="false" /> | ||||
|         <service | ||||
|             android:name=".CheckForNewAppVersion" | ||||
|             android:exported="false" /> | ||||
|  | ||||
|         <!-- opting out of sending metrics to Google in Android System WebView --> | ||||
|         <meta-data android:name="android.webkit.WebView.MetricsOptOut" android:value="true" /> | ||||
|   | ||||
| @@ -27,7 +27,7 @@ import org.schabi.newpipe.util.StateSaver; | ||||
| import java.io.IOException; | ||||
| import java.io.InterruptedIOException; | ||||
| import java.net.SocketException; | ||||
| import java.util.Arrays; | ||||
| import java.util.ArrayList; | ||||
| import java.util.Collections; | ||||
| import java.util.List; | ||||
|  | ||||
| @@ -213,37 +213,44 @@ public class App extends MultiDexApplication { | ||||
|     private void initNotificationChannels() { | ||||
|         // Keep the importance below DEFAULT to avoid making noise on every notification update for | ||||
|         // the main and update channels | ||||
|         final NotificationChannelCompat mainChannel = new NotificationChannelCompat | ||||
|         final List<NotificationChannelCompat> 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() { | ||||
|   | ||||
| @@ -1,264 +0,0 @@ | ||||
| package org.schabi.newpipe; | ||||
|  | ||||
| import android.app.Application; | ||||
| import android.app.IntentService; | ||||
| import android.app.PendingIntent; | ||||
| import android.content.Intent; | ||||
| import android.content.SharedPreferences; | ||||
| import android.content.pm.PackageManager; | ||||
| import android.content.pm.Signature; | ||||
| import android.net.Uri; | ||||
| import android.util.Log; | ||||
|  | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.core.app.NotificationCompat; | ||||
| import androidx.core.app.NotificationManagerCompat; | ||||
| import androidx.core.content.pm.PackageInfoCompat; | ||||
| import androidx.preference.PreferenceManager; | ||||
|  | ||||
| import com.grack.nanojson.JsonObject; | ||||
| import com.grack.nanojson.JsonParser; | ||||
| import com.grack.nanojson.JsonParserException; | ||||
|  | ||||
| import org.schabi.newpipe.error.ErrorInfo; | ||||
| import org.schabi.newpipe.error.ErrorUtil; | ||||
| import org.schabi.newpipe.error.UserAction; | ||||
| import org.schabi.newpipe.extractor.downloader.Response; | ||||
| import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; | ||||
|  | ||||
| import java.io.ByteArrayInputStream; | ||||
| import java.io.IOException; | ||||
| import java.io.InputStream; | ||||
| import java.security.MessageDigest; | ||||
| import java.security.NoSuchAlgorithmException; | ||||
| import java.security.cert.CertificateEncodingException; | ||||
| import java.security.cert.CertificateException; | ||||
| import java.security.cert.CertificateFactory; | ||||
| import java.security.cert.X509Certificate; | ||||
| import java.util.List; | ||||
|  | ||||
| public final class CheckForNewAppVersion extends IntentService { | ||||
|     public CheckForNewAppVersion() { | ||||
|         super("CheckForNewAppVersion"); | ||||
|     } | ||||
|  | ||||
|     private static final boolean DEBUG = MainActivity.DEBUG; | ||||
|     private static final String TAG = CheckForNewAppVersion.class.getSimpleName(); | ||||
|  | ||||
|     // Public key of the certificate that is used in NewPipe release versions | ||||
|     private static final String RELEASE_CERT_PUBLIC_KEY_SHA1 | ||||
|             = "B0:2E:90:7C:1C:D6:FC:57:C3:35:F0:88:D0:8F:50:5F:94:E4:D2:15"; | ||||
|     private static final String NEWPIPE_API_URL = "https://newpipe.net/api/data.json"; | ||||
|  | ||||
|     /** | ||||
|      * Method to get the APK's SHA1 key. See https://stackoverflow.com/questions/9293019/#22506133. | ||||
|      * | ||||
|      * @param application The application | ||||
|      * @return String with the APK's SHA1 fingerprint in hexadecimal | ||||
|      */ | ||||
|     @NonNull | ||||
|     private static String getCertificateSHA1Fingerprint(@NonNull final Application application) { | ||||
|         final List<Signature> signatures; | ||||
|         try { | ||||
|             signatures = PackageInfoCompat.getSignatures(application.getPackageManager(), | ||||
|                     application.getPackageName()); | ||||
|         } catch (final PackageManager.NameNotFoundException e) { | ||||
|             ErrorUtil.createNotification(application, new ErrorInfo(e, | ||||
|                     UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not find package info")); | ||||
|             return ""; | ||||
|         } | ||||
|         if (signatures.isEmpty()) { | ||||
|             return ""; | ||||
|         } | ||||
|  | ||||
|         final X509Certificate c; | ||||
|         try { | ||||
|             final byte[] cert = signatures.get(0).toByteArray(); | ||||
|             final InputStream input = new ByteArrayInputStream(cert); | ||||
|             final CertificateFactory cf = CertificateFactory.getInstance("X509"); | ||||
|             c = (X509Certificate) cf.generateCertificate(input); | ||||
|         } catch (final CertificateException e) { | ||||
|             ErrorUtil.createNotification(application, new ErrorInfo(e, | ||||
|                     UserAction.CHECK_FOR_NEW_APP_VERSION, "Certificate error")); | ||||
|             return ""; | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             final MessageDigest md = MessageDigest.getInstance("SHA1"); | ||||
|             final byte[] publicKey = md.digest(c.getEncoded()); | ||||
|             return byte2HexFormatted(publicKey); | ||||
|         } catch (NoSuchAlgorithmException | CertificateEncodingException e) { | ||||
|             ErrorUtil.createNotification(application, new ErrorInfo(e, | ||||
|                     UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not retrieve SHA1 key")); | ||||
|             return ""; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static String byte2HexFormatted(final byte[] arr) { | ||||
|         final StringBuilder str = new StringBuilder(arr.length * 2); | ||||
|  | ||||
|         for (int i = 0; i < arr.length; i++) { | ||||
|             String h = Integer.toHexString(arr[i]); | ||||
|             final int l = h.length(); | ||||
|             if (l == 1) { | ||||
|                 h = "0" + h; | ||||
|             } | ||||
|             if (l > 2) { | ||||
|                 h = h.substring(l - 2, l); | ||||
|             } | ||||
|             str.append(h.toUpperCase()); | ||||
|             if (i < (arr.length - 1)) { | ||||
|                 str.append(':'); | ||||
|             } | ||||
|         } | ||||
|         return str.toString(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Method to compare the current and latest available app version. | ||||
|      * If a newer version is available, we show the update notification. | ||||
|      * | ||||
|      * @param application    The application | ||||
|      * @param versionName    Name of new version | ||||
|      * @param apkLocationUrl Url with the new apk | ||||
|      * @param versionCode    Code of new version | ||||
|      */ | ||||
|     private static void compareAppVersionAndShowNotification(@NonNull final Application application, | ||||
|                                                              final String versionName, | ||||
|                                                              final String apkLocationUrl, | ||||
|                                                              final int versionCode) { | ||||
|         if (BuildConfig.VERSION_CODE >= versionCode) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // A pending intent to open the apk location url in the browser. | ||||
|         final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkLocationUrl)); | ||||
|         intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); | ||||
|         final PendingIntent pendingIntent | ||||
|                 = PendingIntent.getActivity(application, 0, intent, 0); | ||||
|  | ||||
|         final String channelId = application | ||||
|                 .getString(R.string.app_update_notification_channel_id); | ||||
|         final NotificationCompat.Builder notificationBuilder | ||||
|                 = new NotificationCompat.Builder(application, channelId) | ||||
|                 .setSmallIcon(R.drawable.ic_newpipe_update) | ||||
|                 .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) | ||||
|                 .setContentIntent(pendingIntent) | ||||
|                 .setAutoCancel(true) | ||||
|                 .setContentTitle(application | ||||
|                         .getString(R.string.app_update_notification_content_title)) | ||||
|                 .setContentText(application | ||||
|                         .getString(R.string.app_update_notification_content_text) | ||||
|                         + " " + versionName); | ||||
|  | ||||
|         final NotificationManagerCompat notificationManager | ||||
|                 = NotificationManagerCompat.from(application); | ||||
|         notificationManager.notify(2000, notificationBuilder.build()); | ||||
|     } | ||||
|  | ||||
|     public static boolean isReleaseApk(@NonNull final App app) { | ||||
|         return getCertificateSHA1Fingerprint(app).equals(RELEASE_CERT_PUBLIC_KEY_SHA1); | ||||
|     } | ||||
|  | ||||
|     private void checkNewVersion() throws IOException, ReCaptchaException { | ||||
|         final App app = App.getApp(); | ||||
|  | ||||
|         final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app); | ||||
|         final NewVersionManager manager = new NewVersionManager(); | ||||
|  | ||||
|         // Check if the current apk is a github one or not. | ||||
|         if (!isReleaseApk(app)) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Check if the last request has happened a certain time ago | ||||
|         // to reduce the number of API requests. | ||||
|         final long expiry = prefs.getLong(app.getString(R.string.update_expiry_key), 0); | ||||
|         if (!manager.isExpired(expiry)) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Make a network request to get latest NewPipe data. | ||||
|         final Response response = DownloaderImpl.getInstance().get(NEWPIPE_API_URL); | ||||
|         handleResponse(response, manager, prefs, app); | ||||
|     } | ||||
|  | ||||
|     private void handleResponse(@NonNull final Response response, | ||||
|                                 @NonNull final NewVersionManager manager, | ||||
|                                 @NonNull final SharedPreferences prefs, | ||||
|                                 @NonNull final App app) { | ||||
|         try { | ||||
|             // Store a timestamp which needs to be exceeded, | ||||
|             // before a new request to the API is made. | ||||
|             final long newExpiry = manager | ||||
|                     .coerceExpiry(response.getHeader("expires")); | ||||
|             prefs.edit() | ||||
|                     .putLong(app.getString(R.string.update_expiry_key), newExpiry) | ||||
|                     .apply(); | ||||
|         } catch (final Exception e) { | ||||
|             if (DEBUG) { | ||||
|                 Log.w(TAG, "Could not extract and save new expiry date", e); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Parse the json from the response. | ||||
|         try { | ||||
|  | ||||
|             final JsonObject githubStableObject = JsonParser.object() | ||||
|                     .from(response.responseBody()).getObject("flavors") | ||||
|                     .getObject("github").getObject("stable"); | ||||
|  | ||||
|             final String versionName = githubStableObject | ||||
|                     .getString("version"); | ||||
|             final int versionCode = githubStableObject | ||||
|                     .getInt("version_code"); | ||||
|             final String apkLocationUrl = githubStableObject | ||||
|                     .getString("apk"); | ||||
|  | ||||
|             compareAppVersionAndShowNotification(app, versionName, | ||||
|                     apkLocationUrl, versionCode); | ||||
|         } catch (final JsonParserException e) { | ||||
|             // Most likely something is wrong in data received from NEWPIPE_API_URL. | ||||
|             // Do not alarm user and fail silently. | ||||
|             if (DEBUG) { | ||||
|                 Log.w(TAG, "Could not get NewPipe API: invalid json", e); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Start a new service which | ||||
|      * checks if all conditions for performing a version check are met, | ||||
|      * fetches the API endpoint {@link #NEWPIPE_API_URL} containing info | ||||
|      * about the latest NewPipe version | ||||
|      * and displays a notification about ana available update. | ||||
|      * <br> | ||||
|      * Following conditions need to be met, before data is request from the server: | ||||
|      * <ul> | ||||
|      * <li> 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.</li> | ||||
|      * <li>The user enabled searching for and notifying about updates in the settings.</li> | ||||
|      * <li>The app did not recently check for updates. | ||||
|      * We do not want to make unnecessary connections and DOS our servers.</li> | ||||
|      * </ul> | ||||
|      * <b>Must not be executed</b> when the app is in background. | ||||
|      */ | ||||
|     public static void startNewVersionCheckService() { | ||||
|         final Intent intent = new Intent(App.getApp().getApplicationContext(), | ||||
|                 CheckForNewAppVersion.class); | ||||
|         App.getApp().startService(intent); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     protected void onHandleIntent(@Nullable final Intent intent) { | ||||
|         try { | ||||
|             checkNewVersion(); | ||||
|         } catch (final IOException e) { | ||||
|             Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e); | ||||
|         } catch (final ReCaptchaException e) { | ||||
|             Log.e(TAG, "ReCaptchaException should never happen here.", e); | ||||
|         } | ||||
|  | ||||
|     } | ||||
| } | ||||
| @@ -20,7 +20,6 @@ | ||||
|  | ||||
| package org.schabi.newpipe; | ||||
|  | ||||
| import static org.schabi.newpipe.CheckForNewAppVersion.startNewVersionCheckService; | ||||
| import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; | ||||
|  | ||||
| import android.content.BroadcastReceiver; | ||||
| @@ -72,6 +71,7 @@ import org.schabi.newpipe.fragments.BackPressable; | ||||
| import org.schabi.newpipe.fragments.MainFragment; | ||||
| import org.schabi.newpipe.fragments.detail.VideoDetailFragment; | ||||
| import org.schabi.newpipe.fragments.list.search.SearchFragment; | ||||
| import org.schabi.newpipe.local.feed.notifications.NotificationWorker; | ||||
| import org.schabi.newpipe.player.Player; | ||||
| import org.schabi.newpipe.player.event.OnKeyDownListener; | ||||
| import org.schabi.newpipe.player.helper.PlayerHolder; | ||||
| @@ -159,11 +159,14 @@ public class MainActivity extends AppCompatActivity { | ||||
|         } catch (final Exception e) { | ||||
|             ErrorUtil.showUiErrorSnackbar(this, "Setting up drawer", e); | ||||
|         } | ||||
|  | ||||
|         if (DeviceUtils.isTv(this)) { | ||||
|             FocusOverlayView.setupFocusObserver(this); | ||||
|         } | ||||
|         openMiniPlayerUponPlayerStarted(); | ||||
|  | ||||
|         // Schedule worker for checking for new streams and creating corresponding notifications | ||||
|         // if this is enabled by the user. | ||||
|         NotificationWorker.initialize(this); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
| @@ -174,10 +177,9 @@ public class MainActivity extends AppCompatActivity { | ||||
|         final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app); | ||||
|  | ||||
|         if (prefs.getBoolean(app.getString(R.string.update_app_key), true)) { | ||||
|             // Start the service which is checking all conditions | ||||
|             // Start the worker which is checking all conditions | ||||
|             // and eventually searching for a new version. | ||||
|             // The service searching for a new NewPipe version must not be started in background. | ||||
|             startNewVersionCheckService(); | ||||
|             NewVersionWorker.enqueueNewVersionCheckingWork(app); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -227,7 +229,7 @@ public class MainActivity extends AppCompatActivity { | ||||
|             drawerLayoutBinding.navigation.getMenu() | ||||
|                     .add(R.id.menu_tabs_group, kioskId, 0, KioskTranslator | ||||
|                             .getTranslatedKioskName(ks, this)) | ||||
|                     .setIcon(KioskTranslator.getKioskIcon(ks, this)); | ||||
|                     .setIcon(KioskTranslator.getKioskIcon(ks)); | ||||
|             kioskId++; | ||||
|         } | ||||
|  | ||||
| @@ -719,7 +721,7 @@ public class MainActivity extends AppCompatActivity { | ||||
|             if (toggle != null) { | ||||
|                 toggle.syncState(); | ||||
|                 toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> mainBinding.getRoot() | ||||
|                         .openDrawer(GravityCompat.START)); | ||||
|                         .open()); | ||||
|                 mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED); | ||||
|             } | ||||
|         } else { | ||||
|   | ||||
| @@ -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(); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -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() | ||||
|     } | ||||
| } | ||||
							
								
								
									
										163
									
								
								app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										163
									
								
								app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,163 @@ | ||||
| package org.schabi.newpipe | ||||
|  | ||||
| import android.app.PendingIntent | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import android.util.Log | ||||
| import androidx.core.app.NotificationCompat | ||||
| import androidx.core.app.NotificationManagerCompat | ||||
| import androidx.core.content.edit | ||||
| import androidx.core.net.toUri | ||||
| import androidx.preference.PreferenceManager | ||||
| import androidx.work.OneTimeWorkRequest | ||||
| import androidx.work.WorkManager | ||||
| import androidx.work.WorkRequest | ||||
| import androidx.work.Worker | ||||
| import androidx.work.WorkerParameters | ||||
| import com.grack.nanojson.JsonParser | ||||
| import com.grack.nanojson.JsonParserException | ||||
| import org.schabi.newpipe.extractor.downloader.Response | ||||
| import org.schabi.newpipe.extractor.exceptions.ReCaptchaException | ||||
| import org.schabi.newpipe.util.ReleaseVersionUtil.coerceUpdateCheckExpiry | ||||
| import org.schabi.newpipe.util.ReleaseVersionUtil.isLastUpdateCheckExpired | ||||
| import org.schabi.newpipe.util.ReleaseVersionUtil.isReleaseApk | ||||
| import java.io.IOException | ||||
|  | ||||
| class NewVersionWorker( | ||||
|     context: Context, | ||||
|     workerParams: WorkerParameters | ||||
| ) : Worker(context, workerParams) { | ||||
|  | ||||
|     /** | ||||
|      * Method to compare the current and latest available app version. | ||||
|      * If a newer version is available, we show the update notification. | ||||
|      * | ||||
|      * @param versionName    Name of new version | ||||
|      * @param apkLocationUrl Url with the new apk | ||||
|      * @param versionCode    Code of new version | ||||
|      */ | ||||
|     private fun compareAppVersionAndShowNotification( | ||||
|         versionName: String, | ||||
|         apkLocationUrl: String?, | ||||
|         versionCode: Int | ||||
|     ) { | ||||
|         if (BuildConfig.VERSION_CODE >= versionCode) { | ||||
|             return | ||||
|         } | ||||
|         val app = App.getApp() | ||||
|  | ||||
|         // A pending intent to open the apk location url in the browser. | ||||
|         val intent = Intent(Intent.ACTION_VIEW, apkLocationUrl?.toUri()) | ||||
|         intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) | ||||
|         val pendingIntent = PendingIntent.getActivity(app, 0, intent, 0) | ||||
|         val channelId = app.getString(R.string.app_update_notification_channel_id) | ||||
|         val notificationBuilder = NotificationCompat.Builder(app, channelId) | ||||
|             .setSmallIcon(R.drawable.ic_newpipe_update) | ||||
|             .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) | ||||
|             .setContentIntent(pendingIntent) | ||||
|             .setAutoCancel(true) | ||||
|             .setContentTitle(app.getString(R.string.app_update_notification_content_title)) | ||||
|             .setContentText( | ||||
|                 app.getString(R.string.app_update_notification_content_text) + | ||||
|                     " " + versionName | ||||
|             ) | ||||
|         val notificationManager = NotificationManagerCompat.from(app) | ||||
|         notificationManager.notify(2000, notificationBuilder.build()) | ||||
|     } | ||||
|  | ||||
|     @Throws(IOException::class, ReCaptchaException::class) | ||||
|     private fun checkNewVersion() { | ||||
|         // Check if the current apk is a github one or not. | ||||
|         if (!isReleaseApk()) { | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) | ||||
|         // Check if the last request has happened a certain time ago | ||||
|         // to reduce the number of API requests. | ||||
|         val expiry = prefs.getLong(applicationContext.getString(R.string.update_expiry_key), 0) | ||||
|         if (!isLastUpdateCheckExpired(expiry)) { | ||||
|             return | ||||
|         } | ||||
|  | ||||
|         // Make a network request to get latest NewPipe data. | ||||
|         val response = DownloaderImpl.getInstance().get(NEWPIPE_API_URL) | ||||
|         handleResponse(response) | ||||
|     } | ||||
|  | ||||
|     private fun handleResponse(response: Response) { | ||||
|         val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) | ||||
|         try { | ||||
|             // Store a timestamp which needs to be exceeded, | ||||
|             // before a new request to the API is made. | ||||
|             val newExpiry = coerceUpdateCheckExpiry(response.getHeader("expires")) | ||||
|             prefs.edit { | ||||
|                 putLong(applicationContext.getString(R.string.update_expiry_key), newExpiry) | ||||
|             } | ||||
|         } catch (e: Exception) { | ||||
|             if (DEBUG) { | ||||
|                 Log.w(TAG, "Could not extract and save new expiry date", e) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Parse the json from the response. | ||||
|         try { | ||||
|             val githubStableObject = JsonParser.`object`() | ||||
|                 .from(response.responseBody()).getObject("flavors") | ||||
|                 .getObject("github").getObject("stable") | ||||
|  | ||||
|             val versionName = githubStableObject.getString("version") | ||||
|             val versionCode = githubStableObject.getInt("version_code") | ||||
|             val apkLocationUrl = githubStableObject.getString("apk") | ||||
|             compareAppVersionAndShowNotification(versionName, apkLocationUrl, versionCode) | ||||
|         } catch (e: JsonParserException) { | ||||
|             // Most likely something is wrong in data received from NEWPIPE_API_URL. | ||||
|             // Do not alarm user and fail silently. | ||||
|             if (DEBUG) { | ||||
|                 Log.w(TAG, "Could not get NewPipe API: invalid json", e) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun doWork(): Result { | ||||
|         try { | ||||
|             checkNewVersion() | ||||
|         } catch (e: IOException) { | ||||
|             Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e) | ||||
|             return Result.failure() | ||||
|         } catch (e: ReCaptchaException) { | ||||
|             Log.e(TAG, "ReCaptchaException should never happen here.", e) | ||||
|             return Result.failure() | ||||
|         } | ||||
|         return Result.success() | ||||
|     } | ||||
|  | ||||
|     companion object { | ||||
|         private val DEBUG = MainActivity.DEBUG | ||||
|         private val TAG = NewVersionWorker::class.java.simpleName | ||||
|         private const val NEWPIPE_API_URL = "https://newpipe.net/api/data.json" | ||||
|  | ||||
|         /** | ||||
|          * Start a new worker which | ||||
|          * checks if all conditions for performing a version check are met, | ||||
|          * fetches the API endpoint [.NEWPIPE_API_URL] containing info | ||||
|          * about the latest NewPipe version | ||||
|          * and displays a notification about ana available update. | ||||
|          * <br></br> | ||||
|          * 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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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. | ||||
|   | ||||
| @@ -633,7 +633,7 @@ public class RouterActivity extends AppCompatActivity { | ||||
|                 .subscribe(result -> { | ||||
|                     final List<VideoStream> sortedVideoStreams = ListHelper | ||||
|                             .getSortedStreamVideosList(this, result.getVideoStreams(), | ||||
|                                     result.getVideoOnlyStreams(), false); | ||||
|                                     result.getVideoOnlyStreams(), false, false); | ||||
|                     final int selectedVideoStreamIndex = ListHelper | ||||
|                             .getDefaultResolutionIndex(this, sortedVideoStreams); | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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() | ||||
|                 } | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -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"; | ||||
|   | ||||
| @@ -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() { | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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<List<SubscriptionEntity>> | ||||
|  | ||||
|     @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<List<SubscriptionEntity>> | ||||
| } | ||||
|   | ||||
| @@ -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<StreamHistoryEntity | ||||
|     @Query("DELETE FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") | ||||
|     public abstract int deleteStreamHistory(long streamId); | ||||
|  | ||||
|     @RewriteQueriesToDropUnusedColumns | ||||
|     @Query("SELECT * FROM " + STREAM_TABLE | ||||
|  | ||||
|             // Select the latest entry and watch count for each stream id on history table | ||||
|   | ||||
| @@ -2,6 +2,7 @@ package org.schabi.newpipe.database.playlist.dao; | ||||
|  | ||||
| import androidx.room.Dao; | ||||
| import androidx.room.Query; | ||||
| import androidx.room.RewriteQueriesToDropUnusedColumns; | ||||
| import androidx.room.Transaction; | ||||
|  | ||||
| import org.schabi.newpipe.database.BasicDAO; | ||||
| @@ -52,6 +53,7 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> { | ||||
|             + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") | ||||
|     Flowable<Integer> getMaximumIndexOf(long playlistId); | ||||
|  | ||||
|     @RewriteQueriesToDropUnusedColumns | ||||
|     @Transaction | ||||
|     @Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN " | ||||
|             // get ids of streams of the given playlist | ||||
|   | ||||
| @@ -39,6 +39,9 @@ abstract class StreamDAO : BasicDAO<StreamEntity> { | ||||
|     @Insert(onConflict = OnConflictStrategy.IGNORE) | ||||
|     internal abstract fun silentInsertAllInternal(streams: List<StreamEntity>): List<Long> | ||||
|  | ||||
|     @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  | ||||
|   | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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<SubscriptionEntity> { | ||||
|     ) | ||||
|     abstract fun getSubscriptionsFiltered(filter: String): Flowable<List<SubscriptionEntity>> | ||||
|  | ||||
|     @RewriteQueriesToDropUnusedColumns | ||||
|     @Query( | ||||
|         """ | ||||
|         SELECT * FROM subscriptions s | ||||
| @@ -47,6 +49,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> { | ||||
|         currentGroupId: Long | ||||
|     ): Flowable<List<SubscriptionEntity>> | ||||
|  | ||||
|     @RewriteQueriesToDropUnusedColumns | ||||
|     @Query( | ||||
|         """ | ||||
|         SELECT * FROM subscriptions s | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -61,6 +61,7 @@ import org.schabi.newpipe.util.FilenameUtils; | ||||
| import org.schabi.newpipe.util.ListHelper; | ||||
| import org.schabi.newpipe.util.PermissionHelper; | ||||
| import org.schabi.newpipe.util.SecondaryStreamHelper; | ||||
| import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; | ||||
| import org.schabi.newpipe.util.StreamItemAdapter; | ||||
| import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper; | ||||
| import org.schabi.newpipe.util.ThemeHelper; | ||||
| @@ -151,7 +152,7 @@ public class DownloadDialog extends DialogFragment | ||||
|     public static DownloadDialog newInstance(final Context context, final StreamInfo info) { | ||||
|         final ArrayList<VideoStream> streamsList = new ArrayList<>(ListHelper | ||||
|                 .getSortedStreamVideosList(context, info.getVideoStreams(), | ||||
|                         info.getVideoOnlyStreams(), false)); | ||||
|                         info.getVideoOnlyStreams(), false, false)); | ||||
|         final int selectedStreamIndex = ListHelper.getDefaultResolutionIndex(context, streamsList); | ||||
|  | ||||
|         final DownloadDialog instance = newInstance(info); | ||||
| @@ -321,21 +322,15 @@ public class DownloadDialog extends DialogFragment | ||||
|         final int threads = prefs.getInt(getString(R.string.default_download_threads), 3); | ||||
|         dialogBinding.threadsCount.setText(String.valueOf(threads)); | ||||
|         dialogBinding.threads.setProgress(threads - 1); | ||||
|         dialogBinding.threads.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { | ||||
|         dialogBinding.threads.setOnSeekBarChangeListener(new SimpleOnSeekBarChangeListener() { | ||||
|             @Override | ||||
|             public void onProgressChanged(final SeekBar seekbar, final int progress, | ||||
|             public void onProgressChanged(@NonNull final SeekBar seekbar, final int progress, | ||||
|                                           final boolean fromUser) { | ||||
|                 final int newProgress = progress + 1; | ||||
|                 prefs.edit().putInt(getString(R.string.default_download_threads), newProgress) | ||||
|                         .apply(); | ||||
|                 dialogBinding.threadsCount.setText(String.valueOf(newProgress)); | ||||
|             } | ||||
|  | ||||
|             @Override | ||||
|             public void onStartTrackingTouch(final SeekBar p1) { } | ||||
|  | ||||
|             @Override | ||||
|             public void onStopTrackingTouch(final SeekBar p1) { } | ||||
|         }); | ||||
|  | ||||
|         fetchStreamsSize(); | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| package org.schabi.newpipe.error | ||||
|  | ||||
| import android.app.Activity | ||||
| import android.app.NotificationManager | ||||
| import android.app.PendingIntent | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| @@ -10,7 +9,7 @@ import android.os.Build | ||||
| import android.view.View | ||||
| import android.widget.Toast | ||||
| import androidx.core.app.NotificationCompat | ||||
| import androidx.core.content.ContextCompat | ||||
| import androidx.core.app.NotificationManagerCompat | ||||
| import androidx.fragment.app.Fragment | ||||
| import com.google.android.material.snackbar.Snackbar | ||||
| import org.schabi.newpipe.R | ||||
| @@ -105,13 +104,6 @@ class ErrorUtil { | ||||
|          */ | ||||
|         @JvmStatic | ||||
|         fun createNotification(context: Context, errorInfo: ErrorInfo) { | ||||
|             val notificationManager = | ||||
|                 ContextCompat.getSystemService(context, NotificationManager::class.java) | ||||
|             if (notificationManager == null) { | ||||
|                 // this should never happen, but just in case open error activity | ||||
|                 openActivity(context, errorInfo) | ||||
|             } | ||||
|  | ||||
|             var pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT | ||||
|             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { | ||||
|                 pendingIntentFlags = pendingIntentFlags or PendingIntent.FLAG_IMMUTABLE | ||||
| @@ -122,7 +114,13 @@ class ErrorUtil { | ||||
|                     context, | ||||
|                     context.getString(R.string.error_report_channel_id) | ||||
|                 ) | ||||
|                     .setSmallIcon(R.drawable.ic_bug_report) | ||||
|                     .setSmallIcon( | ||||
|                         // the vector drawable icon causes crashes on KitKat devices | ||||
|                         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) | ||||
|                             R.drawable.ic_bug_report | ||||
|                         else | ||||
|                             android.R.drawable.stat_notify_error | ||||
|                     ) | ||||
|                     .setContentTitle(context.getString(R.string.error_report_notification_title)) | ||||
|                     .setContentText(context.getString(errorInfo.messageStringId)) | ||||
|                     .setAutoCancel(true) | ||||
| @@ -135,7 +133,8 @@ class ErrorUtil { | ||||
|                         ) | ||||
|                     ) | ||||
|  | ||||
|             notificationManager!!.notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build()) | ||||
|             NotificationManagerCompat.from(context) | ||||
|                 .notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build()) | ||||
|  | ||||
|             // since the notification is silent, also show a toast, otherwise the user is confused | ||||
|             Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT) | ||||
|   | ||||
| @@ -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; | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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(); | ||||
|   | ||||
| @@ -43,7 +43,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout; | ||||
| import androidx.core.content.ContextCompat; | ||||
| import androidx.preference.PreferenceManager; | ||||
|  | ||||
| import com.google.android.exoplayer2.ExoPlaybackException; | ||||
| import com.google.android.exoplayer2.PlaybackException; | ||||
| import com.google.android.exoplayer2.PlaybackParameters; | ||||
| import com.google.android.material.appbar.AppBarLayout; | ||||
| import com.google.android.material.bottomsheet.BottomSheetBehavior; | ||||
| @@ -1617,6 +1617,7 @@ public final class VideoDetailFragment | ||||
|                 activity, | ||||
|                 info.getVideoStreams(), | ||||
|                 info.getVideoOnlyStreams(), | ||||
|                 false, | ||||
|                 false); | ||||
|         selectedVideoStreamIndex = ListHelper | ||||
|                 .getDefaultResolutionIndex(activity, sortedVideoStreams); | ||||
| @@ -1883,9 +1884,8 @@ public final class VideoDetailFragment | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void onPlayerError(final ExoPlaybackException error) { | ||||
|         if (error.type == ExoPlaybackException.TYPE_SOURCE | ||||
|                 || error.type == ExoPlaybackException.TYPE_UNEXPECTED) { | ||||
|     public void onPlayerError(final PlaybackException error, final boolean isCatchableException) { | ||||
|         if (!isCatchableException) { | ||||
|             // Properly exit from fullscreen | ||||
|             toggleFullscreenIfInFullscreenMode(); | ||||
|             hideMainPlayerOnLoadingNewStream(); | ||||
|   | ||||
| @@ -15,6 +15,7 @@ import androidx.appcompat.app.AlertDialog; | ||||
|  | ||||
| import com.google.android.exoplayer2.C; | ||||
| import com.google.android.exoplayer2.ExoPlaybackException; | ||||
| import com.google.android.exoplayer2.PlaybackException; | ||||
|  | ||||
| import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.databinding.ListRadioIconItemBinding; | ||||
| @@ -28,6 +29,10 @@ import java.util.LinkedHashMap; | ||||
| import java.util.Map; | ||||
| import java.util.function.Supplier; | ||||
|  | ||||
| import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW; | ||||
| import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_DECODING_FAILED; | ||||
| import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_UNSPECIFIED; | ||||
|  | ||||
| /** | ||||
|  * Outsourced logic for crashing the player in the {@link VideoDetailFragment}. | ||||
|  */ | ||||
| @@ -51,7 +56,8 @@ public final class VideoDetailPlayerCrasher { | ||||
|         exceptionTypes.put( | ||||
|                 "Source", | ||||
|                 () -> ExoPlaybackException.createForSource( | ||||
|                         new IOException(defaultMsg) | ||||
|                         new IOException(defaultMsg), | ||||
|                         ERROR_CODE_BEHIND_LIVE_WINDOW | ||||
|                 ) | ||||
|         ); | ||||
|         exceptionTypes.put( | ||||
| @@ -61,13 +67,16 @@ public final class VideoDetailPlayerCrasher { | ||||
|                         "Dummy renderer", | ||||
|                         0, | ||||
|                         null, | ||||
|                         C.FORMAT_HANDLED | ||||
|                         C.FORMAT_HANDLED, | ||||
|                         /*isRecoverable=*/false, | ||||
|                         ERROR_CODE_DECODING_FAILED | ||||
|                 ) | ||||
|         ); | ||||
|         exceptionTypes.put( | ||||
|                 "Unexpected", | ||||
|                 () -> ExoPlaybackException.createForUnexpected( | ||||
|                         new RuntimeException(defaultMsg) | ||||
|                         new RuntimeException(defaultMsg), | ||||
|                         ERROR_CODE_UNSPECIFIED | ||||
|                 ) | ||||
|         ); | ||||
|         exceptionTypes.put( | ||||
| @@ -139,7 +148,7 @@ public final class VideoDetailPlayerCrasher { | ||||
|  | ||||
|     /** | ||||
|      * Note that this method does not crash the underlying exoplayer directly (it's not possible). | ||||
|      * It simply supplies a Exception to {@link Player#onPlayerError(ExoPlaybackException)}. | ||||
|      * It simply supplies a Exception to {@link Player#onPlayerError(PlaybackException)}. | ||||
|      * @param player | ||||
|      * @param exception | ||||
|      */ | ||||
|   | ||||
| @@ -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<I, N> extends BaseStateFragment<I> | ||||
|         implements ListViewContract<I, N>, StateSaver.WriteRead, | ||||
|         SharedPreferences.OnSharedPreferenceChangeListener { | ||||
| @@ -268,11 +260,11 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> | ||||
|  | ||||
|             @Override | ||||
|             public void held(final StreamInfoItem selectedItem) { | ||||
|                 showStreamDialog(selectedItem); | ||||
|                 showInfoItemDialog(selectedItem); | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<ChannelInfoItem>() { | ||||
|         infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<>() { | ||||
|             @Override | ||||
|             public void selected(final ChannelInfoItem selectedItem) { | ||||
|                 try { | ||||
| @@ -288,7 +280,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture<PlaylistInfoItem>() { | ||||
|         infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture<>() { | ||||
|             @Override | ||||
|             public void selected(final PlaylistInfoItem selectedItem) { | ||||
|                 try { | ||||
| @@ -350,7 +342,8 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> | ||||
|         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<I, N> extends BaseStateFragment<I> | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     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<StreamDialogEntry> 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(); | ||||
|     } | ||||
|  | ||||
|     /*////////////////////////////////////////////////////////////////////////// | ||||
|   | ||||
| @@ -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<I extends ListInfo> | ||||
|         extends BaseListFragment<I, ListExtractor.InfoItemsPage> { | ||||
| public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInfo<I>> | ||||
|         extends BaseListFragment<L, ListExtractor.InfoItemsPage<I>> { | ||||
|     @State | ||||
|     protected int serviceId = Constants.NO_SERVICE_ID; | ||||
|     @State | ||||
| @@ -37,7 +38,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo> | ||||
|     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<I extends ListInfo> | ||||
|     @SuppressWarnings("unchecked") | ||||
|     public void readFrom(@NonNull final Queue<Object> 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<I extends ListInfo> | ||||
|      * @param forceLoad allow or disallow the result to come from the cache | ||||
|      * @return Rx {@link Single} containing the {@link ListInfo} | ||||
|      */ | ||||
|     protected abstract Single<I> loadResult(boolean forceLoad); | ||||
|     protected abstract Single<L> loadResult(boolean forceLoad); | ||||
|  | ||||
|     @Override | ||||
|     public void startLoading(final boolean forceLoad) { | ||||
| @@ -140,7 +141,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo> | ||||
|         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<I extends ListInfo> | ||||
|      * | ||||
|      * @return Rx {@link Single} containing the {@link ListExtractor.InfoItemsPage} | ||||
|      */ | ||||
|     protected abstract Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic(); | ||||
|     protected abstract Single<ListExtractor.InfoItemsPage<I>> loadMoreItemsLogic(); | ||||
|  | ||||
|     @Override | ||||
|     protected void loadMoreItems() { | ||||
| @@ -194,7 +195,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo> | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void handleNextItems(final ListExtractor.InfoItemsPage result) { | ||||
|     public void handleNextItems(final ListExtractor.InfoItemsPage<I> result) { | ||||
|         super.handleNextItems(result); | ||||
|  | ||||
|         currentNextPage = result.getNextPage(); | ||||
| @@ -218,7 +219,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo> | ||||
|     //////////////////////////////////////////////////////////////////////////*/ | ||||
|  | ||||
|     @Override | ||||
|     public void handleResult(@NonNull final I result) { | ||||
|     public void handleResult(@NonNull final L result) { | ||||
|         super.handleResult(result); | ||||
|  | ||||
|         name = result.getName(); | ||||
|   | ||||
| @@ -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<ChannelInfo> | ||||
| public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, ChannelInfo> | ||||
|         implements View.OnClickListener { | ||||
|  | ||||
|     private static final int BUTTON_DEBOUNCE_INTERVAL = 100; | ||||
| @@ -84,6 +88,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> | ||||
|     private PlaylistControlBinding playlistControlBinding; | ||||
|  | ||||
|     private MenuItem menuRssButton; | ||||
|     private MenuItem menuNotifyButton; | ||||
|  | ||||
|     public static ChannelFragment getInstance(final int serviceId, final String url, | ||||
|                                               final String name) { | ||||
| @@ -179,6 +184,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> | ||||
|                         + "menu = [" + menu + "], inflater = [" + inflater + "]"); | ||||
|             } | ||||
|             menuRssButton = menu.findItem(R.id.menu_item_rss); | ||||
|             menuNotifyButton = menu.findItem(R.id.menu_item_notify); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -188,6 +194,11 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> | ||||
|             case R.id.action_settings: | ||||
|                 NavigationHelper.openSettings(requireContext()); | ||||
|                 break; | ||||
|             case R.id.menu_item_notify: | ||||
|                 final boolean value = !item.isChecked(); | ||||
|                 item.setEnabled(false); | ||||
|                 setNotify(value); | ||||
|                 break; | ||||
|             case R.id.menu_item_rss: | ||||
|                 if (currentInfo != null) { | ||||
|                     ShareUtils.openUrlInBrowser( | ||||
| @@ -232,15 +243,22 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> | ||||
|                 .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<SubscriptionEntity> 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<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription, | ||||
| @@ -320,6 +338,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> | ||||
|                         info.getAvatarUrl(), | ||||
|                         info.getDescription(), | ||||
|                         info.getSubscriberCount()); | ||||
|                 updateNotifyButton(null); | ||||
|                 subscribeButtonMonitor = monitorSubscribeButton( | ||||
|                         headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info)); | ||||
|             } else { | ||||
| @@ -327,6 +346,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> | ||||
|                     Log.d(TAG, "Found subscription to this channel!"); | ||||
|                 } | ||||
|                 final SubscriptionEntity subscription = subscriptionEntities.get(0); | ||||
|                 updateNotifyButton(subscription); | ||||
|                 subscribeButtonMonitor = monitorSubscribeButton( | ||||
|                         headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription)); | ||||
|             } | ||||
| @@ -369,12 +389,51 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> | ||||
|                 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<ListExtractor.InfoItemsPage> loadMoreItemsLogic() { | ||||
|     protected Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() { | ||||
|         return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPage); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -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<CommentsInfo> { | ||||
| public class CommentsFragment extends BaseListInfoFragment<CommentsInfoItem, CommentsInfo> { | ||||
|     private final CompositeDisposable disposables = new CompositeDisposable(); | ||||
|  | ||||
|     private TextView emptyStateDesc; | ||||
| @@ -67,7 +68,7 @@ public class CommentsFragment extends BaseListInfoFragment<CommentsInfo> { | ||||
|     //////////////////////////////////////////////////////////////////////////*/ | ||||
|  | ||||
|     @Override | ||||
|     protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() { | ||||
|     protected Single<ListExtractor.InfoItemsPage<CommentsInfoItem>> loadMoreItemsLogic() { | ||||
|         return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
|  * </p> | ||||
|  */ | ||||
|  | ||||
| public class KioskFragment extends BaseListInfoFragment<KioskInfo> { | ||||
| public class KioskFragment extends BaseListInfoFragment<StreamInfoItem, KioskInfo> { | ||||
|     @State | ||||
|     String kioskId = ""; | ||||
|     String kioskTranslatedName; | ||||
| @@ -145,7 +146,7 @@ public class KioskFragment extends BaseListInfoFragment<KioskInfo> { | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() { | ||||
|     public Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() { | ||||
|         return ExtractorHelper.getMoreKioskItems(serviceId, url, currentNextPage); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| package org.schabi.newpipe.fragments.list.playlist; | ||||
|  | ||||
| import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; | ||||
| import static org.schabi.newpipe.ktx.ViewUtils.animate; | ||||
| import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; | ||||
|  | ||||
| import android.app.Activity; | ||||
| import android.content.Context; | ||||
| import android.content.res.ColorStateList; | ||||
| import android.os.Bundle; | ||||
| import android.text.TextUtils; | ||||
| import android.util.Log; | ||||
| @@ -19,6 +18,10 @@ import android.view.ViewGroup; | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.appcompat.content.res.AppCompatResources; | ||||
| import androidx.core.content.ContextCompat; | ||||
|  | ||||
| import com.google.android.material.shape.CornerFamily; | ||||
| import com.google.android.material.shape.ShapeAppearanceModel; | ||||
|  | ||||
| import org.reactivestreams.Subscriber; | ||||
| import org.reactivestreams.Subscription; | ||||
| @@ -36,24 +39,20 @@ import org.schabi.newpipe.extractor.ServiceList; | ||||
| import org.schabi.newpipe.extractor.playlist.PlaylistInfo; | ||||
| import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper; | ||||
| import org.schabi.newpipe.extractor.stream.StreamInfoItem; | ||||
| import org.schabi.newpipe.extractor.stream.StreamType; | ||||
| import org.schabi.newpipe.fragments.list.BaseListInfoFragment; | ||||
| import org.schabi.newpipe.info_list.InfoItemDialog; | ||||
| import org.schabi.newpipe.info_list.dialog.InfoItemDialog; | ||||
| import org.schabi.newpipe.local.playlist.RemotePlaylistManager; | ||||
| import org.schabi.newpipe.player.MainPlayer.PlayerType; | ||||
| import org.schabi.newpipe.player.helper.PlayerHolder; | ||||
| import org.schabi.newpipe.player.playqueue.PlayQueue; | ||||
| import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; | ||||
| import org.schabi.newpipe.util.ExtractorHelper; | ||||
| import org.schabi.newpipe.util.Localization; | ||||
| import org.schabi.newpipe.util.NavigationHelper; | ||||
| import org.schabi.newpipe.util.PicassoHelper; | ||||
| import org.schabi.newpipe.util.StreamDialogEntry; | ||||
| import org.schabi.newpipe.util.external_communication.KoreUtils; | ||||
| import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; | ||||
| import org.schabi.newpipe.util.external_communication.ShareUtils; | ||||
|  | ||||
| import java.util.ArrayList; | ||||
| import java.util.Arrays; | ||||
| import java.util.List; | ||||
| import java.util.concurrent.atomic.AtomicBoolean; | ||||
| import java.util.function.Supplier; | ||||
| @@ -64,7 +63,7 @@ import io.reactivex.rxjava3.core.Single; | ||||
| import io.reactivex.rxjava3.disposables.CompositeDisposable; | ||||
| import io.reactivex.rxjava3.disposables.Disposable; | ||||
|  | ||||
| public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> { | ||||
| public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, PlaylistInfo> { | ||||
|  | ||||
|     private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG"; | ||||
|  | ||||
| @@ -140,60 +139,22 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> { | ||||
|     } | ||||
|  | ||||
|     @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<StreamDialogEntry> entries = new ArrayList<>(); | ||||
|  | ||||
|         if (PlayerHolder.getInstance().isPlayQueueReady()) { | ||||
|             entries.add(StreamDialogEntry.enqueue); | ||||
|  | ||||
|             if (PlayerHolder.getInstance().getQueueSize() > 1) { | ||||
|                 entries.add(StreamDialogEntry.enqueue_next); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (item.getStreamType() == StreamType.AUDIO_STREAM) { | ||||
|             entries.addAll(Arrays.asList( | ||||
|                     StreamDialogEntry.start_here_on_background, | ||||
|                     StreamDialogEntry.append_playlist, | ||||
|                     StreamDialogEntry.share | ||||
|             )); | ||||
|         } else  { | ||||
|             entries.addAll(Arrays.asList( | ||||
|                     StreamDialogEntry.start_here_on_background, | ||||
|                     StreamDialogEntry.start_here_on_popup, | ||||
|                     StreamDialogEntry.append_playlist, | ||||
|                     StreamDialogEntry.share | ||||
|             )); | ||||
|         } | ||||
|         entries.add(StreamDialogEntry.open_in_browser); | ||||
|         if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) { | ||||
|             entries.add(StreamDialogEntry.play_with_kodi); | ||||
|         } | ||||
|  | ||||
|         // show "mark as watched" only when watch history is enabled | ||||
|         if (StreamDialogEntry.shouldAddMarkAsWatched(item.getStreamType(), context)) { | ||||
|             entries.add( | ||||
|                     StreamDialogEntry.mark_as_watched | ||||
|             ); | ||||
|         } | ||||
|         if (!isNullOrEmpty(item.getUploaderUrl())) { | ||||
|             entries.add(StreamDialogEntry.show_channel_details); | ||||
|         } | ||||
|  | ||||
|         StreamDialogEntry.setEnabledEntries(entries); | ||||
|  | ||||
|         StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItem) -> | ||||
|                 NavigationHelper.playOnBackgroundPlayer(context, | ||||
|                         getPlayQueueStartingAt(infoItem), true)); | ||||
|  | ||||
|         new InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context), | ||||
|                 (dialog, which) -> StreamDialogEntry.clickOn(which, this, item)).show(); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
| @@ -249,7 +210,7 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> { | ||||
|     //////////////////////////////////////////////////////////////////////////*/ | ||||
|  | ||||
|     @Override | ||||
|     protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() { | ||||
|     protected Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() { | ||||
|         return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPage); | ||||
|     } | ||||
|  | ||||
| @@ -328,9 +289,14 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> { | ||||
|                 && (YoutubeParsingHelper.isYoutubeMixId(result.getId()) | ||||
|                 || YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) { | ||||
|             // this is an auto-generated playlist (e.g. Youtube mix), so a radio is shown | ||||
|             headerBinding.uploaderAvatarView.setDisableCircularTransformation(true); | ||||
|             headerBinding.uploaderAvatarView.setBorderColor( | ||||
|                     getResources().getColor(R.color.transparent_background_color)); | ||||
|             final ShapeAppearanceModel model = ShapeAppearanceModel.builder() | ||||
|                     .setAllCorners(CornerFamily.ROUNDED, 0f) | ||||
|                     .build(); // this turns the image back into a square | ||||
|             headerBinding.uploaderAvatarView.setShapeAppearanceModel(model); | ||||
|             headerBinding.uploaderAvatarView.setStrokeColor( | ||||
|                     ColorStateList.valueOf(ContextCompat.getColor( | ||||
|                             requireContext(), R.color.transparent_background_color)) | ||||
|             ); | ||||
|             headerBinding.uploaderAvatarView.setImageDrawable( | ||||
|                     AppCompatResources.getDrawable(requireContext(), | ||||
|                     R.drawable.ic_radio) | ||||
|   | ||||
| @@ -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)); | ||||
|     } | ||||
|   | ||||
| @@ -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<RelatedItemInfo> | ||||
| public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, RelatedItemInfo> | ||||
|         implements SharedPreferences.OnSharedPreferenceChangeListener { | ||||
|     private static final String INFO_KEY = "related_info_key"; | ||||
|  | ||||
| @@ -86,7 +87,7 @@ public class RelatedItemsFragment extends BaseListInfoFragment<RelatedItemInfo> | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() { | ||||
|     protected Single<ListExtractor.InfoItemsPage<InfoItem>> loadMoreItemsLogic() { | ||||
|         return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -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(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,356 @@ | ||||
| package org.schabi.newpipe.info_list.dialog; | ||||
|  | ||||
| import static org.schabi.newpipe.MainActivity.DEBUG; | ||||
|  | ||||
| import android.app.Activity; | ||||
| import android.content.Context; | ||||
| import android.content.DialogInterface; | ||||
| import android.os.Build; | ||||
| import android.util.Log; | ||||
| import android.view.View; | ||||
| import android.widget.TextView; | ||||
|  | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.appcompat.app.AlertDialog; | ||||
| import androidx.fragment.app.Fragment; | ||||
| import androidx.preference.PreferenceManager; | ||||
|  | ||||
| import org.schabi.newpipe.App; | ||||
| import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.error.ErrorInfo; | ||||
| import org.schabi.newpipe.error.ErrorUtil; | ||||
| import org.schabi.newpipe.error.UserAction; | ||||
| import org.schabi.newpipe.extractor.InfoItem; | ||||
| import org.schabi.newpipe.extractor.stream.StreamInfoItem; | ||||
| import org.schabi.newpipe.extractor.stream.StreamType; | ||||
| import org.schabi.newpipe.player.helper.PlayerHolder; | ||||
| import org.schabi.newpipe.util.external_communication.KoreUtils; | ||||
|  | ||||
| import java.util.ArrayList; | ||||
| import java.util.List; | ||||
| import java.util.stream.Stream; | ||||
|  | ||||
| /** | ||||
|  * Dialog for a {@link StreamInfoItem}. | ||||
|  * The dialog's content are actions that can be performed on the {@link StreamInfoItem}. | ||||
|  * This dialog is mostly used for longpress context menus. | ||||
|  */ | ||||
| public final class InfoItemDialog { | ||||
|     private static final String TAG = Build.class.getSimpleName(); | ||||
|     /** | ||||
|      * Ideally, {@link InfoItemDialog} would extend {@link AlertDialog}. | ||||
|      * However, extending {@link AlertDialog} requires many additional lines | ||||
|      * and brings more complexity to this class, especially the constructor. | ||||
|      * To circumvent this, an {@link AlertDialog.Builder} is used in the constructor. | ||||
|      * Its result is stored in this class variable to allow access via the {@link #show()} method. | ||||
|      */ | ||||
|     private final AlertDialog dialog; | ||||
|  | ||||
|     private InfoItemDialog(@NonNull final Activity activity, | ||||
|                            @NonNull final Fragment fragment, | ||||
|                            @NonNull final StreamInfoItem info, | ||||
|                            @NonNull final List<StreamDialogEntry> 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(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * <p>Builder to generate a {@link InfoItemDialog} for a {@link StreamInfoItem}.</p> | ||||
|      * Use {@link #addEntry(StreamDialogDefaultEntry)} | ||||
|      * and {@link #addAllEntries(StreamDialogDefaultEntry...)} to add options to the dialog. | ||||
|      * <br> | ||||
|      * 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<StreamDialogEntry> entries = new ArrayList<>(); | ||||
|         private final boolean addDefaultEntriesAutomatically; | ||||
|  | ||||
|         /** | ||||
|          * <p>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.</p> | ||||
|          * The dialog has the following structure: | ||||
|          * <pre> | ||||
|          *     + - - - - - - - - - - - - - - - - - - - - - -+ | ||||
|          *     | 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                       | | ||||
|          *     + - - - - - - - - - - - - - - - - - - - - - -+ | ||||
|          * </pre> | ||||
|          * 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 <code>activity, context</code> | ||||
|          *         or resources is <code>null</code> | ||||
|          */ | ||||
|         public Builder(final Activity activity, | ||||
|                        final Context context, | ||||
|                        @NonNull final Fragment fragment, | ||||
|                        @NonNull final StreamInfoItem infoItem) { | ||||
|             this(activity, context, fragment, infoItem, true); | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * <p>Create an instance of this {@link Builder} for a {@link StreamInfoItem}.</p> | ||||
|          * <p>If {@code addDefaultEntriesAutomatically} is set to {@code true}, | ||||
|          * some default entries are added to the top and bottom of the dialog.</p> | ||||
|          * The dialog has the following structure: | ||||
|          * <pre> | ||||
|          *     + - - - - - - - - - - - - - - - - - - - - - -+ | ||||
|          *     | 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                       | | ||||
|          *     + - - - - - - - - - - - - - - - - - - - - - -+ | ||||
|          * </pre> | ||||
|          * 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}. | ||||
|          *        <br/> | ||||
|          *        Entries added with {@link #addEntry(StreamDialogDefaultEntry)} and | ||||
|          *        {@link #addAllEntries(StreamDialogDefaultEntry...)} are added in between. | ||||
|          * @throws IllegalArgumentException if <code>activity, context</code> | ||||
|          * or resources is <code>null</code> | ||||
|          */ | ||||
|         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; | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * <p>Change an entries' action that is called when the entry is selected.</p> | ||||
|          * <p><strong>Warning:</strong> 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.</p> | ||||
|          * @param entry the entry to change | ||||
|          * @param action the action to perform when the entry is selected | ||||
|          * @return the current {@link Builder} instance | ||||
|          */ | ||||
|         public Builder setAction(@NonNull final StreamDialogDefaultEntry entry, | ||||
|                               @NonNull final StreamDialogEntry.StreamDialogEntryAction action) { | ||||
|             for (int i = 0; i < entries.size(); i++) { | ||||
|                 if (entries.get(i).resource == entry.resource) { | ||||
|                     entries.set(i, new StreamDialogEntry(entry.resource, action)); | ||||
|                     return this; | ||||
|                 } | ||||
|             } | ||||
|             return this; | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Adds {@link StreamDialogDefaultEntry#ENQUEUE} if the player is open and | ||||
|          * {@link StreamDialogDefaultEntry#ENQUEUE_NEXT} if there are multiple streams | ||||
|          * in the play queue. | ||||
|          * @return the current {@link Builder} instance | ||||
|          */ | ||||
|         public Builder addEnqueueEntriesIfNeeded() { | ||||
|             if (PlayerHolder.getInstance().isPlayQueueReady()) { | ||||
|                 addEntry(StreamDialogDefaultEntry.ENQUEUE); | ||||
|  | ||||
|                 if (PlayerHolder.getInstance().getQueueSize() > 1) { | ||||
|                     addEntry(StreamDialogDefaultEntry.ENQUEUE_NEXT); | ||||
|                 } | ||||
|             } | ||||
|             return this; | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Adds the {@link StreamDialogDefaultEntry#START_HERE_ON_BACKGROUND}. | ||||
|          * If the {@link #infoItem} is not a pure audio (live) stream, | ||||
|          * {@link StreamDialogDefaultEntry#START_HERE_ON_POPUP} is added, too. | ||||
|          * @return the current {@link Builder} instance | ||||
|          */ | ||||
|         public Builder addStartHereEntries() { | ||||
|             addEntry(StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND); | ||||
|             if (infoItem.getStreamType() != StreamType.AUDIO_STREAM | ||||
|                     && infoItem.getStreamType() != StreamType.AUDIO_LIVE_STREAM) { | ||||
|                 addEntry(StreamDialogDefaultEntry.START_HERE_ON_POPUP); | ||||
|             } | ||||
|             return this; | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Adds {@link StreamDialogDefaultEntry#MARK_AS_WATCHED} if the watch history is enabled | ||||
|          * and the stream is not a livestream. | ||||
|          * @return the current {@link Builder} instance | ||||
|          */ | ||||
|         public Builder addMarkAsWatchedEntryIfNeeded() { | ||||
|             final boolean isWatchHistoryEnabled = PreferenceManager | ||||
|                     .getDefaultSharedPreferences(context) | ||||
|                     .getBoolean(context.getString(R.string.enable_watch_history_key), false); | ||||
|             if (isWatchHistoryEnabled | ||||
|                     && infoItem.getStreamType() != StreamType.LIVE_STREAM | ||||
|                     && infoItem.getStreamType() != StreamType.AUDIO_LIVE_STREAM) { | ||||
|                 addEntry(StreamDialogDefaultEntry.MARK_AS_WATCHED); | ||||
|             } | ||||
|             return this; | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Adds the {@link StreamDialogDefaultEntry#PLAY_WITH_KODI} entry if it is needed. | ||||
|          * @return the current {@link Builder} instance | ||||
|          */ | ||||
|         public Builder addPlayWithKodiEntryIfNeeded() { | ||||
|             if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { | ||||
|                 addEntry(StreamDialogDefaultEntry.PLAY_WITH_KODI); | ||||
|             } | ||||
|             return this; | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * Add the entries which are usually at the top of the action list. | ||||
|          * <br/> | ||||
|          * 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())); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|  | ||||
| /** | ||||
|  * <p> | ||||
|  *     This enum provides entries that are accepted | ||||
|  *     by the {@link InfoItemDialog.Builder}. | ||||
|  * </p> | ||||
|  * <p> | ||||
|  *     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 <code>onClick()</code>). | ||||
|  *     <br/> | ||||
|  *     They action can be overridden by using the Builder's | ||||
|  *     {@link InfoItemDialog.Builder#setAction( | ||||
|  *     StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)} | ||||
|  *     method. | ||||
|  * </p> | ||||
|  */ | ||||
| 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); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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); | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -228,6 +228,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View | ||||
|         return count; | ||||
|     } | ||||
|  | ||||
|     @SuppressWarnings("FinalParameters") | ||||
|     @Override | ||||
|     public int getItemViewType(int position) { | ||||
|         if (DEBUG) { | ||||
| @@ -300,6 +301,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @SuppressWarnings("FinalParameters") | ||||
|     @Override | ||||
|     public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int position) { | ||||
|         if (DEBUG) { | ||||
|   | ||||
| @@ -33,8 +33,16 @@ public final class PlaylistAppendDialog extends PlaylistDialog { | ||||
|  | ||||
|     private final CompositeDisposable playlistDisposables = new CompositeDisposable(); | ||||
|  | ||||
|     public PlaylistAppendDialog(final List<StreamEntity> 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<StreamEntity> 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); | ||||
|   | ||||
| @@ -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<StreamEntity> 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<StreamEntity> streamEntities) { | ||||
|         final PlaylistCreationDialog dialog = new PlaylistCreationDialog(); | ||||
|         dialog.setStreamEntities(streamEntities); | ||||
|         return dialog; | ||||
|     } | ||||
|  | ||||
|     /*////////////////////////////////////////////////////////////////////////// | ||||
|   | ||||
| @@ -31,10 +31,6 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave | ||||
|  | ||||
|     private org.schabi.newpipe.util.SavedState savedState; | ||||
|  | ||||
|     public PlaylistDialog(final List<StreamEntity> 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<StreamEntity> 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)) | ||||
|                 ); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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<Long> { | ||||
|         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<StreamInfoItem>, | ||||
|   | ||||
| @@ -50,7 +50,6 @@ import androidx.recyclerview.widget.GridLayoutManager | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| import com.xwray.groupie.GroupieAdapter | ||||
| import com.xwray.groupie.Item | ||||
| import com.xwray.groupie.OnAsyncUpdateListener | ||||
| import com.xwray.groupie.OnItemClickListener | ||||
| import com.xwray.groupie.OnItemLongClickListener | ||||
| import icepick.State | ||||
| @@ -68,25 +67,21 @@ import org.schabi.newpipe.error.UserAction | ||||
| import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException | ||||
| import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException | ||||
| import org.schabi.newpipe.extractor.stream.StreamInfoItem | ||||
| import org.schabi.newpipe.extractor.stream.StreamType | ||||
| import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty | ||||
| import org.schabi.newpipe.fragments.BaseStateFragment | ||||
| import org.schabi.newpipe.info_list.InfoItemDialog | ||||
| import org.schabi.newpipe.info_list.dialog.InfoItemDialog | ||||
| import org.schabi.newpipe.ktx.animate | ||||
| import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling | ||||
| import org.schabi.newpipe.ktx.slideUp | ||||
| import org.schabi.newpipe.local.feed.item.StreamItem | ||||
| import org.schabi.newpipe.local.feed.service.FeedLoadService | ||||
| import org.schabi.newpipe.local.subscription.SubscriptionManager | ||||
| import org.schabi.newpipe.player.helper.PlayerHolder | ||||
| import org.schabi.newpipe.util.DeviceUtils | ||||
| import org.schabi.newpipe.util.Localization | ||||
| import org.schabi.newpipe.util.NavigationHelper | ||||
| import org.schabi.newpipe.util.StreamDialogEntry | ||||
| import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams | ||||
| import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout | ||||
| import java.time.OffsetDateTime | ||||
| import java.util.ArrayList | ||||
| import java.util.function.Consumer | ||||
|  | ||||
| class FeedFragment : BaseStateFragment<FeedState>() { | ||||
| @@ -143,7 +138,7 @@ class FeedFragment : BaseStateFragment<FeedState>() { | ||||
|         val factory = FeedViewModel.Factory(requireContext(), groupId) | ||||
|         viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java) | ||||
|         showPlayedItems = viewModel.getShowPlayedItemsFromPreferences() | ||||
|         viewModel.stateLiveData.observe(viewLifecycleOwner, { it?.let(::handleResult) }) | ||||
|         viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) } | ||||
|  | ||||
|         groupAdapter = GroupieAdapter().apply { | ||||
|             setOnItemClickListener(listenerStreamItem) | ||||
| @@ -356,53 +351,12 @@ class FeedFragment : BaseStateFragment<FeedState>() { | ||||
|         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<StreamDialogEntry>() | ||||
|         if (PlayerHolder.getInstance().isPlayQueueReady) { | ||||
|             entries.add(StreamDialogEntry.enqueue) | ||||
|  | ||||
|             if (PlayerHolder.getInstance().queueSize > 1) { | ||||
|                 entries.add(StreamDialogEntry.enqueue_next) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (item.streamType == StreamType.AUDIO_STREAM) { | ||||
|             entries.addAll( | ||||
|                 listOf( | ||||
|                     StreamDialogEntry.start_here_on_background, | ||||
|                     StreamDialogEntry.append_playlist, | ||||
|                     StreamDialogEntry.share, | ||||
|                     StreamDialogEntry.open_in_browser | ||||
|                 ) | ||||
|             ) | ||||
|         } else { | ||||
|             entries.addAll( | ||||
|                 listOf( | ||||
|                     StreamDialogEntry.start_here_on_background, | ||||
|                     StreamDialogEntry.start_here_on_popup, | ||||
|                     StreamDialogEntry.append_playlist, | ||||
|                     StreamDialogEntry.share, | ||||
|                     StreamDialogEntry.open_in_browser | ||||
|                 ) | ||||
|             ) | ||||
|         } | ||||
|  | ||||
|         // show "mark as watched" only when watch history is enabled | ||||
|         if (StreamDialogEntry.shouldAddMarkAsWatched(item.streamType, context)) { | ||||
|             entries.add( | ||||
|                 StreamDialogEntry.mark_as_watched | ||||
|             ) | ||||
|         } | ||||
|         entries.add(StreamDialogEntry.show_channel_details) | ||||
|  | ||||
|         StreamDialogEntry.setEnabledEntries(entries) | ||||
|         InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context)) { _, which -> | ||||
|             StreamDialogEntry.clickOn(which, this, item) | ||||
|         }.show() | ||||
|         InfoItemDialog.Builder(activity, context, this, item).create().show() | ||||
|     } | ||||
|  | ||||
|     private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener { | ||||
| @@ -418,7 +372,7 @@ class FeedFragment : BaseStateFragment<FeedState>() { | ||||
|  | ||||
|         override fun onItemLongClick(item: Item<*>, view: View): Boolean { | ||||
|             if (item is StreamItem && !isRefreshing) { | ||||
|                 showStreamDialog(item.streamWithState.stream.toStreamInfoItem()) | ||||
|                 showInfoItemDialog(item.streamWithState.stream.toStreamInfoItem()) | ||||
|                 return true | ||||
|             } | ||||
|             return false | ||||
| @@ -438,14 +392,11 @@ class FeedFragment : BaseStateFragment<FeedState>() { | ||||
|         // This need to be saved in a variable as the update occurs async | ||||
|         val oldOldestSubscriptionUpdate = oldestSubscriptionUpdate | ||||
|  | ||||
|         groupAdapter.updateAsync( | ||||
|             loadedState.items, false, | ||||
|             OnAsyncUpdateListener { | ||||
|                 oldOldestSubscriptionUpdate?.run { | ||||
|                     highlightNewItemsAfter(oldOldestSubscriptionUpdate) | ||||
|                 } | ||||
|         groupAdapter.updateAsync(loadedState.items, false) { | ||||
|             oldOldestSubscriptionUpdate?.run { | ||||
|                 highlightNewItemsAfter(oldOldestSubscriptionUpdate) | ||||
|             } | ||||
|         ) | ||||
|         } | ||||
|  | ||||
|         listState?.run { | ||||
|             feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState) | ||||
| @@ -497,8 +448,7 @@ class FeedFragment : BaseStateFragment<FeedState>() { | ||||
|                 }.subscribeOn(Schedulers.io()) | ||||
|                     .observeOn(AndroidSchedulers.mainThread()) | ||||
|                     .subscribe( | ||||
|                         { | ||||
|                             subscriptionEntity -> | ||||
|                         { subscriptionEntity -> | ||||
|                             handleFeedNotAvailable( | ||||
|                                 subscriptionEntity, | ||||
|                                 t.cause, | ||||
|   | ||||
| @@ -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()) | ||||
|   | ||||
| @@ -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<StreamInfoItem> = 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. | ||||
|          * <br> | ||||
|          * 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 <code>true</code> if notifications are allowed and can be displayed; | ||||
|          * <code>false</code> 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) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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<Result> = 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<NotificationWorker>() | ||||
|                 .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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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) | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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<String>() | ||||
|     private val currentProgress = AtomicInteger(-1) | ||||
|     private val maxProgress = AtomicInteger(-1) | ||||
|     private val cancelSignal = AtomicBoolean() | ||||
|     private val feedResultsHolder = FeedResultsHolder() | ||||
|  | ||||
|     val notification: Flowable<FeedLoadState> = 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<List<Notification<FeedUpdateInfo>>> { | ||||
|         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<StreamInfoItem> | ||||
|  | ||||
|                     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<FeedUpdateInfo>(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. | ||||
|      * <br> | ||||
|      * 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<Notification<FeedUpdateInfo>> { | ||||
|         override fun accept(item: Notification<FeedUpdateInfo>) { | ||||
|             currentProgress.incrementAndGet() | ||||
|             notificationUpdater.onNext(item.value?.name.orEmpty()) | ||||
|  | ||||
|             broadcastProgress() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private inner class DatabaseConsumer : Consumer<List<Notification<FeedUpdateInfo>>> { | ||||
|  | ||||
|         override fun accept(list: List<Notification<FeedUpdateInfo>>) { | ||||
|             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<StreamInfoItem>): List<StreamInfoItem> { | ||||
|             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 | ||||
|     } | ||||
| } | ||||
| @@ -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<String>() | ||||
|     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<StreamInfoItem> | ||||
|  | ||||
|                     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<Pair<Long, ListInfo<StreamInfoItem>>>(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<List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>> { | ||||
|  | ||||
|             override fun onSubscribe(s: Subscription) { | ||||
|                 loadingSubscription = s | ||||
|                 s.request(java.lang.Long.MAX_VALUE) | ||||
|             } | ||||
|  | ||||
|             override fun onNext(notification: List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>) { | ||||
|                 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<List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>> | ||||
|         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<Notification<Pair<Long, ListInfo<StreamInfoItem>>>> | ||||
|         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<String> -> | ||||
|         val throttleAfterFirstEmission = Function { flow: Flowable<FeedLoadState> -> | ||||
|             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<Throwable> | ||||
|  | ||||
|         private val itemsErrorsHolder: MutableList<Throwable> = ArrayList() | ||||
|  | ||||
|         fun addError(error: Throwable) { | ||||
|             itemsErrorsHolder.add(error) | ||||
|         } | ||||
|  | ||||
|         fun addErrors(errors: List<Throwable>) { | ||||
|             itemsErrorsHolder.addAll(errors) | ||||
|         } | ||||
|  | ||||
|         fun ready() { | ||||
|             itemsErrors = itemsErrorsHolder.toList() | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,7 @@ | ||||
| package org.schabi.newpipe.local.feed.service | ||||
|  | ||||
| data class FeedLoadState( | ||||
|     val updateDescription: String, | ||||
|     val maxProgress: Int, | ||||
|     val currentProgress: Int, | ||||
| ) | ||||
| @@ -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<Throwable> | ||||
|         get() = itemsErrorsHolder | ||||
|  | ||||
|     private val itemsErrorsHolder: MutableList<Throwable> = ArrayList() | ||||
|  | ||||
|     fun addError(error: Throwable) { | ||||
|         itemsErrorsHolder.add(error) | ||||
|     } | ||||
|  | ||||
|     fun addErrors(errors: List<Throwable>) { | ||||
|         itemsErrorsHolder.addAll(errors) | ||||
|     } | ||||
| } | ||||
| @@ -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<StreamInfoItem>, | ||||
| ) { | ||||
|     constructor( | ||||
|         subscription: SubscriptionEntity, | ||||
|         listInfo: ListInfo<StreamInfoItem>, | ||||
|     ) : 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<StreamInfoItem> | ||||
| } | ||||
| @@ -84,7 +84,7 @@ public abstract class HistoryEntryAdapter<E, VH extends RecyclerView.ViewHolder> | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void onViewRecycled(final VH holder) { | ||||
|     public void onViewRecycled(@NonNull final VH holder) { | ||||
|         super.onViewRecycled(holder); | ||||
|         holder.itemView.setOnClickListener(null); | ||||
|     } | ||||
|   | ||||
| @@ -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<StreamDialogEntry> 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) { | ||||
|   | ||||
| @@ -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<List<PlaylistStreamEntry>, 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<List<PlaylistSt | ||||
|             @Override | ||||
|             public void held(final LocalItem selectedItem) { | ||||
|                 if (selectedItem instanceof PlaylistStreamEntry) { | ||||
|                     showStreamItemDialog((PlaylistStreamEntry) selectedItem); | ||||
|                     showInfoItemDialog((PlaylistStreamEntry) selectedItem); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
| @@ -355,7 +350,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt | ||||
|                 new AlertDialog.Builder(requireContext()) | ||||
|                         .setMessage(R.string.remove_watched_popup_warning) | ||||
|                         .setTitle(R.string.remove_watched_popup_title) | ||||
|                         .setPositiveButton(R.string.yes, | ||||
|                         .setPositiveButton(R.string.ok, | ||||
|                                 (DialogInterface d, int id) -> removeWatchedStreams(false)) | ||||
|                         .setNeutralButton( | ||||
|                                 R.string.remove_watched_popup_yes_and_partially_watched_videos, | ||||
| @@ -743,70 +738,39 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt | ||||
|         return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0)); | ||||
|     } | ||||
|  | ||||
|     protected void showStreamItemDialog(final PlaylistStreamEntry item) { | ||||
|         final Context context = getContext(); | ||||
|         final Activity activity = getActivity(); | ||||
|         if (context == null || context.getResources() == null || activity == null) { | ||||
|             return; | ||||
|         } | ||||
|     protected void showInfoItemDialog(final PlaylistStreamEntry item) { | ||||
|         final StreamInfoItem infoItem = item.toStreamInfoItem(); | ||||
|  | ||||
|         final ArrayList<StreamDialogEntry> 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) { | ||||
|   | ||||
| @@ -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); | ||||
|     } | ||||
|   | ||||
| @@ -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<StreamInfoItem>) { | ||||
|         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() | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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<PickerSubscriptionItem>, t2: List<Long> -> t1 to t2.toSet() } | ||||
|         ) | ||||
|             subscriptionsFlowable, feedDatabaseManager.subscriptionIdsForGroup(groupId) | ||||
|         ) { t1: List<PickerSubscriptionItem>, t2: List<Long> -> t1 to t2.toSet() } | ||||
|         .subscribeOn(Schedulers.io()) | ||||
|         .subscribe(mutableSubscriptionsLiveData::postValue) | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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<SubscriptionItem> items, final JsonSink writer, | ||||
|     public static void writeTo(final List<SubscriptionItem> items, | ||||
|                                final JsonAppendableWriter writer, | ||||
|                                @Nullable final ImportExportEventListener eventListener) { | ||||
|         if (eventListener != null) { | ||||
|             eventListener.onSizeReceived(items.size()); | ||||
|   | ||||
| @@ -1,7 +1,10 @@ | ||||
| package org.schabi.newpipe.player; | ||||
|  | ||||
| import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu; | ||||
| import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; | ||||
| import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; | ||||
|  | ||||
| import android.content.ComponentName; | ||||
| import android.content.Context; | ||||
| import android.content.Intent; | ||||
| import android.content.ServiceConnection; | ||||
| import android.os.Bundle; | ||||
| @@ -23,11 +26,9 @@ import androidx.recyclerview.widget.RecyclerView; | ||||
| import com.google.android.exoplayer2.PlaybackParameters; | ||||
|  | ||||
| import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.database.stream.model.StreamEntity; | ||||
| import org.schabi.newpipe.databinding.ActivityPlayerQueueControlBinding; | ||||
| import org.schabi.newpipe.extractor.stream.StreamInfo; | ||||
| import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; | ||||
| import org.schabi.newpipe.local.dialog.PlaylistDialog; | ||||
| import org.schabi.newpipe.player.event.PlayerEventListener; | ||||
| import org.schabi.newpipe.player.helper.PlaybackParameterDialog; | ||||
| import org.schabi.newpipe.player.playqueue.PlayQueue; | ||||
| @@ -42,13 +43,6 @@ import org.schabi.newpipe.util.PermissionHelper; | ||||
| import org.schabi.newpipe.util.ServiceHelper; | ||||
| import org.schabi.newpipe.util.ThemeHelper; | ||||
|  | ||||
| import java.util.List; | ||||
| import java.util.stream.Collectors; | ||||
|  | ||||
| import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu; | ||||
| import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; | ||||
| import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; | ||||
|  | ||||
| public final class PlayQueueActivity extends AppCompatActivity | ||||
|         implements PlayerEventListener, SeekBar.OnSeekBarChangeListener, | ||||
|         View.OnClickListener, PlaybackParameterDialog.Callback { | ||||
| @@ -129,7 +123,7 @@ public final class PlayQueueActivity extends AppCompatActivity | ||||
|                 NavigationHelper.openSettings(this); | ||||
|                 return true; | ||||
|             case R.id.action_append_playlist: | ||||
|                 appendAllToPlaylist(); | ||||
|                 player.onAddToPlaylistClicked(getSupportFragmentManager()); | ||||
|                 return true; | ||||
|             case R.id.action_playback_speed: | ||||
|                 openPlaybackParameterDialog(); | ||||
| @@ -443,24 +437,6 @@ public final class PlayQueueActivity extends AppCompatActivity | ||||
|         seeking = false; | ||||
|     } | ||||
|  | ||||
|     //////////////////////////////////////////////////////////////////////////// | ||||
|     // Playlist append | ||||
|     //////////////////////////////////////////////////////////////////////////// | ||||
|  | ||||
|     private void appendAllToPlaylist() { | ||||
|         if (player != null && player.getPlayQueue() != null) { | ||||
|             openPlaylistAppendDialog(player.getPlayQueue().getStreams()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void openPlaylistAppendDialog(final List<PlayQueueItem> playQueueItems) { | ||||
|         PlaylistDialog.createCorrespondingDialog( | ||||
|                 getApplicationContext(), | ||||
|                 playQueueItems.stream().map(StreamEntity::new).collect(Collectors.toList()), | ||||
|                 dialog -> dialog.show(getSupportFragmentManager(), TAG) | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     //////////////////////////////////////////////////////////////////////////// | ||||
|     // Binding Service Listener | ||||
|     //////////////////////////////////////////////////////////////////////////// | ||||
| @@ -624,7 +600,6 @@ public final class PlayQueueActivity extends AppCompatActivity | ||||
|  | ||||
|             //2) Icon change accordingly to current App Theme | ||||
|             // using rootView.getContext() because getApplicationContext() didn't work | ||||
|             final Context context = queueControlBinding.getRoot().getContext(); | ||||
|             item.setIcon(player.isMuted() ? R.drawable.ic_volume_off : R.drawable.ic_volume_up); | ||||
|         } | ||||
|     } | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -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(); | ||||
| } | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -3,12 +3,10 @@ package org.schabi.newpipe.player.helper; | ||||
| import android.content.Context; | ||||
| import android.util.Log; | ||||
|  | ||||
| import androidx.annotation.NonNull; | ||||
|  | ||||
| import com.google.android.exoplayer2.database.ExoDatabaseProvider; | ||||
| import com.google.android.exoplayer2.database.StandaloneDatabaseProvider; | ||||
| import com.google.android.exoplayer2.upstream.DataSource; | ||||
| import com.google.android.exoplayer2.upstream.DefaultDataSource; | ||||
| import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; | ||||
| import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; | ||||
| import com.google.android.exoplayer2.upstream.FileDataSource; | ||||
| import com.google.android.exoplayer2.upstream.TransferListener; | ||||
| import com.google.android.exoplayer2.upstream.cache.CacheDataSink; | ||||
| @@ -18,6 +16,8 @@ import com.google.android.exoplayer2.upstream.cache.SimpleCache; | ||||
|  | ||||
| import java.io.File; | ||||
|  | ||||
| import androidx.annotation.NonNull; | ||||
|  | ||||
| /* package-private */ class CacheFactory implements DataSource.Factory { | ||||
|     private static final String TAG = "CacheFactory"; | ||||
|  | ||||
| @@ -25,7 +25,7 @@ import java.io.File; | ||||
|     private static final int CACHE_FLAGS = CacheDataSource.FLAG_BLOCK_ON_CACHE | ||||
|             | CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; | ||||
|  | ||||
|     private final DefaultDataSourceFactory dataSourceFactory; | ||||
|     private final DataSource.Factory dataSourceFactory; | ||||
|     private final File cacheDir; | ||||
|     private final long maxFileSize; | ||||
|  | ||||
| @@ -49,7 +49,9 @@ import java.io.File; | ||||
|                          final long maxFileSize) { | ||||
|         this.maxFileSize = maxFileSize; | ||||
|  | ||||
|         dataSourceFactory = new DefaultDataSourceFactory(context, userAgent, transferListener); | ||||
|         dataSourceFactory = new DefaultDataSource | ||||
|                 .Factory(context, new DefaultHttpDataSource.Factory().setUserAgent(userAgent)) | ||||
|                 .setTransferListener(transferListener); | ||||
|         cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); | ||||
|         if (!cacheDir.exists()) { | ||||
|             //noinspection ResultOfMethodCallIgnored | ||||
| @@ -59,15 +61,16 @@ import java.io.File; | ||||
|         if (cache == null) { | ||||
|             final LeastRecentlyUsedCacheEvictor evictor | ||||
|                     = new LeastRecentlyUsedCacheEvictor(maxCacheSize); | ||||
|             cache = new SimpleCache(cacheDir, evictor, new ExoDatabaseProvider(context)); | ||||
|             cache = new SimpleCache(cacheDir, evictor, new StandaloneDatabaseProvider(context)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public DataSource createDataSource() { | ||||
|         Log.d(TAG, "initExoPlayerCache: cacheDir = " + cacheDir.getAbsolutePath()); | ||||
|  | ||||
|         final DefaultDataSource dataSource = dataSourceFactory.createDataSource(); | ||||
|         final DataSource dataSource = dataSourceFactory.createDataSource(); | ||||
|         final FileDataSource fileSource = new FileDataSource(); | ||||
|         final CacheDataSink dataSink = new CacheDataSink(cache, maxFileSize); | ||||
|  | ||||
| @@ -86,8 +89,8 @@ import java.io.File; | ||||
|  | ||||
|                 Log.d(TAG, "tryDeleteCacheFiles: " + filePath + " deleted = " + deleteSuccessful); | ||||
|             } | ||||
|         } catch (final Exception ignored) { | ||||
|             Log.e(TAG, "Failed to delete file.", ignored); | ||||
|         } catch (final Exception e) { | ||||
|             Log.e(TAG, "Failed to delete file.", e); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -19,7 +19,6 @@ import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; | ||||
| import org.schabi.newpipe.MainActivity; | ||||
| import org.schabi.newpipe.player.mediasession.MediaSessionCallback; | ||||
| import org.schabi.newpipe.player.mediasession.PlayQueueNavigator; | ||||
| import org.schabi.newpipe.player.mediasession.PlayQueuePlaybackController; | ||||
|  | ||||
| import java.util.Optional; | ||||
|  | ||||
| @@ -55,7 +54,6 @@ public class MediaSessionManager { | ||||
|                 .build()); | ||||
|  | ||||
|         sessionConnector = new MediaSessionConnector(mediaSession); | ||||
|         sessionConnector.setControlDispatcher(new PlayQueuePlaybackController(callback)); | ||||
|         sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, callback)); | ||||
|         sessionConnector.setPlayer(player); | ||||
|     } | ||||
| @@ -135,9 +133,7 @@ public class MediaSessionManager { | ||||
|         lastTitleHashCode = title.hashCode(); | ||||
|         lastArtistHashCode = artist.hashCode(); | ||||
|         lastDuration = duration; | ||||
|         if (optAlbumArt.isPresent()) { | ||||
|             lastAlbumArtHashCode = optAlbumArt.get().hashCode(); | ||||
|         } | ||||
|         optAlbumArt.ifPresent(bitmap -> lastAlbumArtHashCode = bitmap.hashCode()); | ||||
|     } | ||||
|  | ||||
|     private boolean checkIfMetadataShouldBeSet( | ||||
|   | ||||
| @@ -9,6 +9,7 @@ import android.os.Bundle; | ||||
| import android.util.Log; | ||||
| import android.view.View; | ||||
| import android.widget.CheckBox; | ||||
| import android.widget.RelativeLayout; | ||||
| import android.widget.SeekBar; | ||||
| import android.widget.TextView; | ||||
|  | ||||
| @@ -19,6 +20,7 @@ import androidx.fragment.app.DialogFragment; | ||||
| import androidx.preference.PreferenceManager; | ||||
|  | ||||
| import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; | ||||
| import org.schabi.newpipe.util.SliderStrategy; | ||||
|  | ||||
| public class PlaybackParameterDialog extends DialogFragment { | ||||
| @@ -37,6 +39,7 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|  | ||||
|     private static final double DEFAULT_TEMPO = 1.00f; | ||||
|     private static final double DEFAULT_PITCH = 1.00f; | ||||
|     private static final int DEFAULT_SEMITONES = 0; | ||||
|     private static final double DEFAULT_STEP = STEP_TWENTY_FIVE_PERCENT_VALUE; | ||||
|     private static final boolean DEFAULT_SKIP_SILENCE = false; | ||||
|  | ||||
| @@ -64,10 +67,11 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|  | ||||
|     private double initialTempo = DEFAULT_TEMPO; | ||||
|     private double initialPitch = DEFAULT_PITCH; | ||||
|     private int initialSemitones = DEFAULT_SEMITONES; | ||||
|     private boolean initialSkipSilence = DEFAULT_SKIP_SILENCE; | ||||
|     private double tempo = DEFAULT_TEMPO; | ||||
|     private double pitch = DEFAULT_PITCH; | ||||
|     private double stepSize = DEFAULT_STEP; | ||||
|     private int semitones = DEFAULT_SEMITONES; | ||||
|  | ||||
|     @Nullable | ||||
|     private SeekBar tempoSlider; | ||||
| @@ -86,9 +90,19 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|     @Nullable | ||||
|     private TextView pitchStepUpText; | ||||
|     @Nullable | ||||
|     private SeekBar semitoneSlider; | ||||
|     @Nullable | ||||
|     private TextView semitoneCurrentText; | ||||
|     @Nullable | ||||
|     private TextView semitoneStepDownText; | ||||
|     @Nullable | ||||
|     private TextView semitoneStepUpText; | ||||
|     @Nullable | ||||
|     private CheckBox unhookingCheckbox; | ||||
|     @Nullable | ||||
|     private CheckBox skipSilenceCheckbox; | ||||
|     @Nullable | ||||
|     private CheckBox adjustBySemitonesCheckbox; | ||||
|  | ||||
|     public static PlaybackParameterDialog newInstance(final double playbackTempo, | ||||
|                                                       final double playbackPitch, | ||||
| @@ -101,6 +115,7 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|  | ||||
|         dialog.tempo = playbackTempo; | ||||
|         dialog.pitch = playbackPitch; | ||||
|         dialog.semitones = dialog.percentToSemitones(playbackPitch); | ||||
|  | ||||
|         dialog.initialSkipSilence = playbackSkipSilence; | ||||
|         return dialog; | ||||
| @@ -111,7 +126,7 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|     //////////////////////////////////////////////////////////////////////////*/ | ||||
|  | ||||
|     @Override | ||||
|     public void onAttach(final Context context) { | ||||
|     public void onAttach(@NonNull final Context context) { | ||||
|         super.onAttach(context); | ||||
|         if (context instanceof Callback) { | ||||
|             callback = (Callback) context; | ||||
| @@ -127,22 +142,22 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|         if (savedInstanceState != null) { | ||||
|             initialTempo = savedInstanceState.getDouble(INITIAL_TEMPO_KEY, DEFAULT_TEMPO); | ||||
|             initialPitch = savedInstanceState.getDouble(INITIAL_PITCH_KEY, DEFAULT_PITCH); | ||||
|             initialSemitones = percentToSemitones(initialPitch); | ||||
|  | ||||
|             tempo = savedInstanceState.getDouble(TEMPO_KEY, DEFAULT_TEMPO); | ||||
|             pitch = savedInstanceState.getDouble(PITCH_KEY, DEFAULT_PITCH); | ||||
|             stepSize = savedInstanceState.getDouble(STEP_SIZE_KEY, DEFAULT_STEP); | ||||
|             semitones = percentToSemitones(pitch); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void onSaveInstanceState(final Bundle outState) { | ||||
|     public void onSaveInstanceState(@NonNull final Bundle outState) { | ||||
|         super.onSaveInstanceState(outState); | ||||
|         outState.putDouble(INITIAL_TEMPO_KEY, initialTempo); | ||||
|         outState.putDouble(INITIAL_PITCH_KEY, initialPitch); | ||||
|  | ||||
|         outState.putDouble(TEMPO_KEY, getCurrentTempo()); | ||||
|         outState.putDouble(PITCH_KEY, getCurrentPitch()); | ||||
|         outState.putDouble(STEP_SIZE_KEY, getCurrentStepSize()); | ||||
|     } | ||||
|  | ||||
|     /*////////////////////////////////////////////////////////////////////////// | ||||
| @@ -160,9 +175,11 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|                 .setView(view) | ||||
|                 .setCancelable(true) | ||||
|                 .setNegativeButton(R.string.cancel, (dialogInterface, i) -> | ||||
|                         setPlaybackParameters(initialTempo, initialPitch, initialSkipSilence)) | ||||
|                         setPlaybackParameters(initialTempo, initialPitch, | ||||
|                                 initialSemitones, initialSkipSilence)) | ||||
|                 .setNeutralButton(R.string.playback_reset, (dialogInterface, i) -> | ||||
|                         setPlaybackParameters(DEFAULT_TEMPO, DEFAULT_PITCH, DEFAULT_SKIP_SILENCE)) | ||||
|                         setPlaybackParameters(DEFAULT_TEMPO, DEFAULT_PITCH, | ||||
|                                 DEFAULT_SEMITONES, DEFAULT_SKIP_SILENCE)) | ||||
|                 .setPositiveButton(R.string.ok, (dialogInterface, i) -> | ||||
|                         setCurrentPlaybackParameters()); | ||||
|  | ||||
| @@ -176,14 +193,49 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|     private void setupControlViews(@NonNull final View rootView) { | ||||
|         setupHookingControl(rootView); | ||||
|         setupSkipSilenceControl(rootView); | ||||
|         setupAdjustBySemitonesControl(rootView); | ||||
|  | ||||
|         setupTempoControl(rootView); | ||||
|         setupPitchControl(rootView); | ||||
|         setupSemitoneControl(rootView); | ||||
|  | ||||
|         togglePitchSliderType(rootView); | ||||
|  | ||||
|         setStepSize(stepSize); | ||||
|         setupStepSizeSelector(rootView); | ||||
|     } | ||||
|  | ||||
|     private void togglePitchSliderType(@NonNull final View rootView) { | ||||
|         final RelativeLayout pitchControl = rootView.findViewById(R.id.pitchControl); | ||||
|         final RelativeLayout semitoneControl = rootView.findViewById(R.id.semitoneControl); | ||||
|  | ||||
|         final View separatorStepSizeSelector = | ||||
|                 rootView.findViewById(R.id.separatorStepSizeSelector); | ||||
|         final RelativeLayout.LayoutParams params = | ||||
|                 (RelativeLayout.LayoutParams) separatorStepSizeSelector.getLayoutParams(); | ||||
|         if (pitchControl != null && semitoneControl != null && unhookingCheckbox != null) { | ||||
|             if (getCurrentAdjustBySemitones()) { | ||||
|                 // replaces pitchControl slider with semitoneControl slider | ||||
|                 pitchControl.setVisibility(View.GONE); | ||||
|                 semitoneControl.setVisibility(View.VISIBLE); | ||||
|                 params.addRule(RelativeLayout.BELOW, R.id.semitoneControl); | ||||
|  | ||||
|                 // forces unhook for semitones | ||||
|                 unhookingCheckbox.setChecked(true); | ||||
|                 unhookingCheckbox.setEnabled(false); | ||||
|  | ||||
|                 setupTempoStepSizeSelector(rootView); | ||||
|             } else { | ||||
|                 semitoneControl.setVisibility(View.GONE); | ||||
|                 pitchControl.setVisibility(View.VISIBLE); | ||||
|                 params.addRule(RelativeLayout.BELOW, R.id.pitchControl); | ||||
|  | ||||
|                 // (re)enables hooking selection | ||||
|                 unhookingCheckbox.setEnabled(true); | ||||
|                 setupCombinedStepSizeSelector(rootView); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void setupTempoControl(@NonNull final View rootView) { | ||||
|         tempoSlider = rootView.findViewById(R.id.tempoSeekbar); | ||||
|         final TextView tempoMinimumText = rootView.findViewById(R.id.tempoMinimumText); | ||||
| @@ -234,23 +286,40 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void setupSemitoneControl(@NonNull final View rootView) { | ||||
|         semitoneSlider = rootView.findViewById(R.id.semitoneSeekbar); | ||||
|         semitoneCurrentText = rootView.findViewById(R.id.semitoneCurrentText); | ||||
|         semitoneStepDownText = rootView.findViewById(R.id.semitoneStepDown); | ||||
|         semitoneStepUpText = rootView.findViewById(R.id.semitoneStepUp); | ||||
|  | ||||
|         if (semitoneCurrentText != null) { | ||||
|             semitoneCurrentText.setText(getSignedSemitonesString(semitones)); | ||||
|         } | ||||
|  | ||||
|         if (semitoneSlider != null) { | ||||
|             setSemitoneSlider(semitones); | ||||
|             semitoneSlider.setOnSeekBarChangeListener(getOnSemitoneChangedListener()); | ||||
|         } | ||||
|  | ||||
|     } | ||||
|  | ||||
|     private void setupHookingControl(@NonNull final View rootView) { | ||||
|         unhookingCheckbox = rootView.findViewById(R.id.unhookCheckbox); | ||||
|         if (unhookingCheckbox != null) { | ||||
|             // restore whether pitch and tempo are unhooked or not | ||||
|             // restores whether pitch and tempo are unhooked or not | ||||
|             unhookingCheckbox.setChecked(PreferenceManager | ||||
|                     .getDefaultSharedPreferences(requireContext()) | ||||
|                     .getBoolean(getString(R.string.playback_unhook_key), true)); | ||||
|  | ||||
|             unhookingCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> { | ||||
|                 // save whether pitch and tempo are unhooked or not | ||||
|                 // saves whether pitch and tempo are unhooked or not | ||||
|                 PreferenceManager.getDefaultSharedPreferences(requireContext()) | ||||
|                         .edit() | ||||
|                         .putBoolean(getString(R.string.playback_unhook_key), isChecked) | ||||
|                         .apply(); | ||||
|  | ||||
|                 if (!isChecked) { | ||||
|                     // when unchecked, slide back to the minimum of current tempo or pitch | ||||
|                     // when unchecked, slides back to the minimum of current tempo or pitch | ||||
|                     final double minimum = Math.min(getCurrentPitch(), getCurrentTempo()); | ||||
|                     setSliders(minimum); | ||||
|                     setCurrentPlaybackParameters(); | ||||
| @@ -268,7 +337,51 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void setupAdjustBySemitonesControl(@NonNull final View rootView) { | ||||
|         adjustBySemitonesCheckbox = rootView.findViewById(R.id.adjustBySemitonesCheckbox); | ||||
|         if (adjustBySemitonesCheckbox != null) { | ||||
|             // restores whether semitone adjustment is used or not | ||||
|             adjustBySemitonesCheckbox.setChecked(PreferenceManager | ||||
|                 .getDefaultSharedPreferences(requireContext()) | ||||
|                 .getBoolean(getString(R.string.playback_adjust_by_semitones_key), true)); | ||||
|  | ||||
|             // stores whether semitone adjustment is used or not | ||||
|             adjustBySemitonesCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> { | ||||
|                 PreferenceManager.getDefaultSharedPreferences(requireContext()) | ||||
|                     .edit() | ||||
|                     .putBoolean(getString(R.string.playback_adjust_by_semitones_key), isChecked) | ||||
|                     .apply(); | ||||
|                 togglePitchSliderType(rootView); | ||||
|                 if (isChecked) { | ||||
|                     setPlaybackParameters( | ||||
|                             getCurrentTempo(), | ||||
|                             getCurrentPitch(), | ||||
|                             Integer.min(12, | ||||
|                                     Integer.max(-12, percentToSemitones(getCurrentPitch()) | ||||
|                             )), | ||||
|                             getCurrentSkipSilence() | ||||
|                     ); | ||||
|                     setSemitoneSlider(Integer.min(12, | ||||
|                             Integer.max(-12, percentToSemitones(getCurrentPitch())) | ||||
|                     )); | ||||
|                 } else { | ||||
|                     setPlaybackParameters( | ||||
|                             getCurrentTempo(), | ||||
|                             semitonesToPercent(getCurrentSemitones()), | ||||
|                             getCurrentSemitones(), | ||||
|                             getCurrentSkipSilence() | ||||
|                     ); | ||||
|                     setPitchSlider(semitonesToPercent(getCurrentSemitones())); | ||||
|                 } | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void setupStepSizeSelector(@NonNull final View rootView) { | ||||
|         setStepSize(PreferenceManager | ||||
|                 .getDefaultSharedPreferences(requireContext()) | ||||
|                 .getFloat(getString(R.string.adjustment_step_key), (float) DEFAULT_STEP)); | ||||
|  | ||||
|         final TextView stepSizeOnePercentText = rootView.findViewById(R.id.stepSizeOnePercent); | ||||
|         final TextView stepSizeFivePercentText = rootView.findViewById(R.id.stepSizeFivePercent); | ||||
|         final TextView stepSizeTenPercentText = rootView.findViewById(R.id.stepSizeTenPercent); | ||||
| @@ -310,8 +423,27 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void setupTempoStepSizeSelector(@NonNull final View rootView) { | ||||
|         final TextView playbackStepTypeText = rootView.findViewById(R.id.playback_step_type); | ||||
|         if (playbackStepTypeText != null) { | ||||
|             playbackStepTypeText.setText(R.string.playback_tempo_step); | ||||
|         } | ||||
|         setupStepSizeSelector(rootView); | ||||
|     } | ||||
|  | ||||
|     private void setupCombinedStepSizeSelector(@NonNull final View rootView) { | ||||
|         final TextView playbackStepTypeText = rootView.findViewById(R.id.playback_step_type); | ||||
|         if (playbackStepTypeText != null) { | ||||
|             playbackStepTypeText.setText(R.string.playback_step); | ||||
|         } | ||||
|         setupStepSizeSelector(rootView); | ||||
|     } | ||||
|  | ||||
|     private void setStepSize(final double stepSize) { | ||||
|         this.stepSize = stepSize; | ||||
|         PreferenceManager.getDefaultSharedPreferences(requireContext()) | ||||
|                 .edit() | ||||
|                 .putFloat(getString(R.string.adjustment_step_key), (float) stepSize) | ||||
|                 .apply(); | ||||
|  | ||||
|         if (tempoStepUpText != null) { | ||||
|             tempoStepUpText.setText(getStepUpPercentString(stepSize)); | ||||
| @@ -344,16 +476,30 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|                 setCurrentPlaybackParameters(); | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         if (semitoneStepDownText != null) { | ||||
|             semitoneStepDownText.setOnClickListener(view -> { | ||||
|                 onSemitoneSliderUpdated(getCurrentSemitones() - 1); | ||||
|                 setCurrentPlaybackParameters(); | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         if (semitoneStepUpText != null) { | ||||
|             semitoneStepUpText.setOnClickListener(view -> { | ||||
|                 onSemitoneSliderUpdated(getCurrentSemitones() + 1); | ||||
|                 setCurrentPlaybackParameters(); | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /*////////////////////////////////////////////////////////////////////////// | ||||
|     // Sliders | ||||
|     //////////////////////////////////////////////////////////////////////////*/ | ||||
|  | ||||
|     private SeekBar.OnSeekBarChangeListener getOnTempoChangedListener() { | ||||
|         return new SeekBar.OnSeekBarChangeListener() { | ||||
|     private SimpleOnSeekBarChangeListener getOnTempoChangedListener() { | ||||
|         return new SimpleOnSeekBarChangeListener() { | ||||
|             @Override | ||||
|             public void onProgressChanged(final SeekBar seekBar, final int progress, | ||||
|             public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress, | ||||
|                                           final boolean fromUser) { | ||||
|                 final double currentTempo = strategy.valueOf(progress); | ||||
|                 if (fromUser) { | ||||
| @@ -361,23 +507,13 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|                     setCurrentPlaybackParameters(); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             @Override | ||||
|             public void onStartTrackingTouch(final SeekBar seekBar) { | ||||
|                 // Do Nothing. | ||||
|             } | ||||
|  | ||||
|             @Override | ||||
|             public void onStopTrackingTouch(final SeekBar seekBar) { | ||||
|                 // Do Nothing. | ||||
|             } | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private SeekBar.OnSeekBarChangeListener getOnPitchChangedListener() { | ||||
|         return new SeekBar.OnSeekBarChangeListener() { | ||||
|     private SimpleOnSeekBarChangeListener getOnPitchChangedListener() { | ||||
|         return new SimpleOnSeekBarChangeListener() { | ||||
|             @Override | ||||
|             public void onProgressChanged(final SeekBar seekBar, final int progress, | ||||
|             public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress, | ||||
|                                           final boolean fromUser) { | ||||
|                 final double currentPitch = strategy.valueOf(progress); | ||||
|                 if (fromUser) { // this change is first in chain | ||||
| @@ -385,23 +521,27 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|                     setCurrentPlaybackParameters(); | ||||
|                 } | ||||
|             } | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private SimpleOnSeekBarChangeListener getOnSemitoneChangedListener() { | ||||
|         return new SimpleOnSeekBarChangeListener() { | ||||
|             @Override | ||||
|             public void onStartTrackingTouch(final SeekBar seekBar) { | ||||
|                 // Do Nothing. | ||||
|             } | ||||
|  | ||||
|             @Override | ||||
|             public void onStopTrackingTouch(final SeekBar seekBar) { | ||||
|                 // Do Nothing. | ||||
|             public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress, | ||||
|                                           final boolean fromUser) { | ||||
|                 // semitone slider supplies values 0 to 24, subtraction by 12 is required | ||||
|                 final int currentSemitones = progress - 12; | ||||
|                 if (fromUser) { // this change is first in chain | ||||
|                     onSemitoneSliderUpdated(currentSemitones); | ||||
|                     // line below also saves semitones as pitch percentages | ||||
|                     onPitchSliderUpdated(semitonesToPercent(currentSemitones)); | ||||
|                     setCurrentPlaybackParameters(); | ||||
|                 } | ||||
|             } | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private void onTempoSliderUpdated(final double newTempo) { | ||||
|         if (unhookingCheckbox == null) { | ||||
|             return; | ||||
|         } | ||||
|         if (!unhookingCheckbox.isChecked()) { | ||||
|             setSliders(newTempo); | ||||
|         } else { | ||||
| @@ -410,9 +550,6 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|     } | ||||
|  | ||||
|     private void onPitchSliderUpdated(final double newPitch) { | ||||
|         if (unhookingCheckbox == null) { | ||||
|             return; | ||||
|         } | ||||
|         if (!unhookingCheckbox.isChecked()) { | ||||
|             setSliders(newPitch); | ||||
|         } else { | ||||
| @@ -420,6 +557,10 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void onSemitoneSliderUpdated(final int newSemitone) { | ||||
|         setSemitoneSlider(newSemitone); | ||||
|     } | ||||
|  | ||||
|     private void setSliders(final double newValue) { | ||||
|         setTempoSlider(newValue); | ||||
|         setPitchSlider(newValue); | ||||
| @@ -439,25 +580,49 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|         pitchSlider.setProgress(strategy.progressOf(newPitch)); | ||||
|     } | ||||
|  | ||||
|     private void setSemitoneSlider(final int newSemitone) { | ||||
|         if (semitoneSlider == null) { | ||||
|             return; | ||||
|         } | ||||
|         semitoneSlider.setProgress(newSemitone + 12); | ||||
|     } | ||||
|  | ||||
|     /*////////////////////////////////////////////////////////////////////////// | ||||
|     // Helper | ||||
|     //////////////////////////////////////////////////////////////////////////*/ | ||||
|  | ||||
|     private void setCurrentPlaybackParameters() { | ||||
|         setPlaybackParameters(getCurrentTempo(), getCurrentPitch(), getCurrentSkipSilence()); | ||||
|         if (getCurrentAdjustBySemitones()) { | ||||
|             setPlaybackParameters( | ||||
|                     getCurrentTempo(), | ||||
|                     semitonesToPercent(getCurrentSemitones()), | ||||
|                     getCurrentSemitones(), | ||||
|                     getCurrentSkipSilence() | ||||
|             ); | ||||
|         } else { | ||||
|             setPlaybackParameters( | ||||
|                     getCurrentTempo(), | ||||
|                     getCurrentPitch(), | ||||
|                     percentToSemitones(getCurrentPitch()), | ||||
|                     getCurrentSkipSilence() | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void setPlaybackParameters(final double newTempo, final double newPitch, | ||||
|                                        final boolean skipSilence) { | ||||
|         if (callback != null && tempoCurrentText != null && pitchCurrentText != null) { | ||||
|                                        final int newSemitones, final boolean skipSilence) { | ||||
|         if (callback != null && tempoCurrentText != null | ||||
|                 && pitchCurrentText != null && semitoneCurrentText != null) { | ||||
|             if (DEBUG) { | ||||
|                 Log.d(TAG, "Setting playback parameters to " | ||||
|                         + "tempo=[" + newTempo + "], " | ||||
|                         + "pitch=[" + newPitch + "]"); | ||||
|                         + "pitch=[" + newPitch + "], " | ||||
|                         + "semitones=[" + newSemitones + "]"); | ||||
|             } | ||||
|  | ||||
|             tempoCurrentText.setText(PlayerHelper.formatSpeed(newTempo)); | ||||
|             pitchCurrentText.setText(PlayerHelper.formatPitch(newPitch)); | ||||
|             semitoneCurrentText.setText(getSignedSemitonesString(newSemitones)); | ||||
|             callback.onPlaybackParameterChanged((float) newTempo, (float) newPitch, skipSilence); | ||||
|         } | ||||
|     } | ||||
| @@ -470,14 +635,19 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|         return pitchSlider == null ? pitch : strategy.valueOf(pitchSlider.getProgress()); | ||||
|     } | ||||
|  | ||||
|     private double getCurrentStepSize() { | ||||
|         return stepSize; | ||||
|     private int getCurrentSemitones() { | ||||
|         // semitoneSlider is absolute, that's why - 12 | ||||
|         return semitoneSlider == null ? semitones : semitoneSlider.getProgress() - 12; | ||||
|     } | ||||
|  | ||||
|     private boolean getCurrentSkipSilence() { | ||||
|         return skipSilenceCheckbox != null && skipSilenceCheckbox.isChecked(); | ||||
|     } | ||||
|  | ||||
|     private boolean getCurrentAdjustBySemitones() { | ||||
|         return adjustBySemitonesCheckbox != null && adjustBySemitonesCheckbox.isChecked(); | ||||
|     } | ||||
|  | ||||
|     @NonNull | ||||
|     private static String getStepUpPercentString(final double percent) { | ||||
|         return STEP_UP_SIGN + getPercentString(percent); | ||||
| @@ -493,8 +663,21 @@ public class PlaybackParameterDialog extends DialogFragment { | ||||
|         return PlayerHelper.formatPitch(percent); | ||||
|     } | ||||
|  | ||||
|     @NonNull | ||||
|     private static String getSignedSemitonesString(final int semitones) { | ||||
|         return semitones > 0 ? "+" + semitones : "" + semitones; | ||||
|     } | ||||
|  | ||||
|     public interface Callback { | ||||
|         void onPlaybackParameterChanged(float playbackTempo, float playbackPitch, | ||||
|                                         boolean playbackSkipSilence); | ||||
|     } | ||||
|  | ||||
|     public double semitonesToPercent(final int inSemitones) { | ||||
|         return Math.pow(2, inSemitones / 12.0); | ||||
|     } | ||||
|  | ||||
|     public int percentToSemitones(final double inPercent) { | ||||
|         return (int) Math.round(12 * Math.log(inPercent) / Math.log(2)); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -2,8 +2,6 @@ package org.schabi.newpipe.player.helper; | ||||
|  | ||||
| import android.content.Context; | ||||
|  | ||||
| import androidx.annotation.NonNull; | ||||
|  | ||||
| import com.google.android.exoplayer2.source.ProgressiveMediaSource; | ||||
| import com.google.android.exoplayer2.source.SingleSampleMediaSource; | ||||
| import com.google.android.exoplayer2.source.dash.DashMediaSource; | ||||
| @@ -13,10 +11,13 @@ import com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistTrack | ||||
| import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource; | ||||
| import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; | ||||
| import com.google.android.exoplayer2.upstream.DataSource; | ||||
| import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; | ||||
| import com.google.android.exoplayer2.upstream.DefaultDataSource; | ||||
| import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; | ||||
| import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; | ||||
| import com.google.android.exoplayer2.upstream.TransferListener; | ||||
|  | ||||
| import androidx.annotation.NonNull; | ||||
|  | ||||
| public class PlayerDataSource { | ||||
|  | ||||
|     public static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000; | ||||
| @@ -31,14 +32,18 @@ public class PlayerDataSource { | ||||
|     private static final int MANIFEST_MINIMUM_RETRY = 5; | ||||
|     private static final int EXTRACTOR_MINIMUM_RETRY = Integer.MAX_VALUE; | ||||
|  | ||||
|     private final int continueLoadingCheckIntervalBytes; | ||||
|     private final DataSource.Factory cacheDataSourceFactory; | ||||
|     private final DataSource.Factory cachelessDataSourceFactory; | ||||
|  | ||||
|     public PlayerDataSource(@NonNull final Context context, @NonNull final String userAgent, | ||||
|     public PlayerDataSource(@NonNull final Context context, | ||||
|                             @NonNull final String userAgent, | ||||
|                             @NonNull final TransferListener transferListener) { | ||||
|         continueLoadingCheckIntervalBytes = PlayerHelper.getProgressiveLoadIntervalBytes(context); | ||||
|         cacheDataSourceFactory = new CacheFactory(context, userAgent, transferListener); | ||||
|         cachelessDataSourceFactory | ||||
|                 = new DefaultDataSourceFactory(context, userAgent, transferListener); | ||||
|         cachelessDataSourceFactory = new DefaultDataSource | ||||
|                 .Factory(context, new DefaultHttpDataSource.Factory().setUserAgent(userAgent)) | ||||
|                 .setTransferListener(transferListener); | ||||
|     } | ||||
|  | ||||
|     public SsMediaSource.Factory getLiveSsMediaSourceFactory() { | ||||
| @@ -91,6 +96,7 @@ public class PlayerDataSource { | ||||
|  | ||||
|     public ProgressiveMediaSource.Factory getExtractorMediaSourceFactory() { | ||||
|         return new ProgressiveMediaSource.Factory(cacheDataSourceFactory) | ||||
|                 .setContinueLoadingCheckIntervalBytes(continueLoadingCheckIntervalBytes) | ||||
|                 .setLoadErrorHandlingPolicy( | ||||
|                         new DefaultLoadErrorHandlingPolicy(EXTRACTOR_MINIMUM_RETRY)); | ||||
|     } | ||||
|   | ||||
| @@ -34,6 +34,7 @@ import androidx.preference.PreferenceManager; | ||||
| import com.google.android.exoplayer2.PlaybackParameters; | ||||
| import com.google.android.exoplayer2.Player.RepeatMode; | ||||
| import com.google.android.exoplayer2.SeekParameters; | ||||
| import com.google.android.exoplayer2.source.ProgressiveMediaSource; | ||||
| import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; | ||||
| import com.google.android.exoplayer2.trackselection.ExoTrackSelection; | ||||
| import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; | ||||
| @@ -77,6 +78,20 @@ public final class PlayerHelper { | ||||
|     private static final NumberFormat SPEED_FORMATTER = new DecimalFormat("0.##x"); | ||||
|     private static final NumberFormat PITCH_FORMATTER = new DecimalFormat("##%"); | ||||
|  | ||||
|     /** | ||||
|      * Maximum opacity allowed for Android 12 and higher to allow touches on other apps when using | ||||
|      * NewPipe's popup player. | ||||
|      * | ||||
|      * <p> | ||||
|      * 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. | ||||
|      * </p> | ||||
|      * | ||||
|      * @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE | ||||
|      */ | ||||
|     private static final float MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER = 0.8f; | ||||
|  | ||||
|     @Retention(SOURCE) | ||||
|     @IntDef({AUTOPLAY_TYPE_ALWAYS, AUTOPLAY_TYPE_WIFI, | ||||
|             AUTOPLAY_TYPE_NEVER}) | ||||
| @@ -143,6 +158,21 @@ public final class PlayerHelper { | ||||
|                 ? " (" + context.getString(R.string.caption_auto_generated) + ")" : ""); | ||||
|     } | ||||
|  | ||||
|     @NonNull | ||||
|     public static String captionLanguageStemOf(@NonNull final String language) { | ||||
|         if (!language.contains("(") || !language.contains(")")) { | ||||
|             return language; | ||||
|         } | ||||
|  | ||||
|         if (language.startsWith("(")) { | ||||
|             // language text is right-to-left | ||||
|             final String[] parts = language.split("\\)"); | ||||
|             return parts[parts.length - 1].trim(); | ||||
|         } | ||||
|  | ||||
|         return language.split("\\(")[0].trim(); | ||||
|     } | ||||
|  | ||||
|     @NonNull | ||||
|     public static String resizeTypeOf(@NonNull final Context context, | ||||
|                                       @ResizeMode final int resizeMode) { | ||||
| @@ -391,6 +421,19 @@ public final class PlayerHelper { | ||||
|                 context.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) == 0; | ||||
|     } | ||||
|  | ||||
|     public static int getProgressiveLoadIntervalBytes(@NonNull final Context context) { | ||||
|         final String preferredIntervalBytes = getPreferences(context).getString( | ||||
|                 context.getString(R.string.progressive_load_interval_key), | ||||
|                 context.getString(R.string.progressive_load_interval_default_value)); | ||||
|  | ||||
|         if (context.getString(R.string.progressive_load_interval_exoplayer_default_value) | ||||
|                 .equals(preferredIntervalBytes)) { | ||||
|             return ProgressiveMediaSource.DEFAULT_LOADING_CHECK_INTERVAL_BYTES; | ||||
|         } | ||||
|         // Keeping the same KiB unit used by ProgressiveMediaSource | ||||
|         return Integer.parseInt(preferredIntervalBytes) * 1024; | ||||
|     } | ||||
|  | ||||
|     //////////////////////////////////////////////////////////////////////////// | ||||
|     // Private helpers | ||||
|     //////////////////////////////////////////////////////////////////////////// | ||||
| @@ -558,6 +601,12 @@ public final class PlayerHelper { | ||||
|                 flags, | ||||
|                 PixelFormat.TRANSLUCENT); | ||||
|  | ||||
|         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { | ||||
|             // Setting maximum opacity allowed for touch events to other apps for Android 12 and | ||||
|             // higher to prevent non interaction when using other apps with the popup player | ||||
|             closeOverlayLayoutParams.alpha = MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER; | ||||
|         } | ||||
|  | ||||
|         closeOverlayLayoutParams.gravity = Gravity.LEFT | Gravity.TOP; | ||||
|         closeOverlayLayoutParams.softInputMode = | ||||
|                 WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE; | ||||
|   | ||||
| @@ -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); | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,41 @@ | ||||
| package org.schabi.newpipe.player.listeners.view | ||||
|  | ||||
| import android.annotation.SuppressLint | ||||
| import android.util.Log | ||||
| import android.view.View | ||||
| import androidx.appcompat.widget.PopupMenu | ||||
| import org.schabi.newpipe.MainActivity | ||||
| import org.schabi.newpipe.extractor.MediaFormat | ||||
| import org.schabi.newpipe.player.Player | ||||
|  | ||||
| /** | ||||
|  * Click listener for the qualityTextView of the player | ||||
|  */ | ||||
| class QualityClickListener( | ||||
|     private val player: Player, | ||||
|     private val qualityPopupMenu: PopupMenu | ||||
| ) : View.OnClickListener { | ||||
|  | ||||
|     companion object { | ||||
|         private const val TAG: String = "QualityClickListener" | ||||
|     } | ||||
|  | ||||
|     @SuppressLint("SetTextI18n") // we don't need I18N because of a " " | ||||
|     override fun onClick(v: View) { | ||||
|         if (MainActivity.DEBUG) { | ||||
|             Log.d(TAG, "onQualitySelectorClicked() called") | ||||
|         } | ||||
|  | ||||
|         qualityPopupMenu.show() | ||||
|         player.isSomePopupMenuVisible = true | ||||
|  | ||||
|         val videoStream = player.selectedVideoStream | ||||
|         if (videoStream != null) { | ||||
|             player.binding.qualityTextView.text = | ||||
|                 MediaFormat.getNameById(videoStream.formatId) + " " + videoStream.resolution | ||||
|         } | ||||
|  | ||||
|         player.saveWasPlaying() | ||||
|         player.manageControlsAfterOnClick(v) | ||||
|     } | ||||
| } | ||||
| @@ -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<Exception> errors; | ||||
|     @Nullable | ||||
|     private final Object extras; | ||||
|  | ||||
|     private ExceptionTag(@NonNull final PlayQueueItem item, | ||||
|                          @NonNull final List<Exception> 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<Exception> errors) { | ||||
|         return new ExceptionTag(playQueueItem, errors, null); | ||||
|     } | ||||
|  | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public List<Exception> 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 <T> Optional<T> getMaybeExtras(@NonNull final Class<T> type) { | ||||
|         return Optional.ofNullable(extras).map(type::cast); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public <T> MediaItemTag withExtras(@NonNull final T extra) { | ||||
|         return new ExceptionTag(item, errors, extra); | ||||
|     } | ||||
| } | ||||
| @@ -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<Exception> getErrors(); | ||||
|  | ||||
|     int getServiceId(); | ||||
|  | ||||
|     String getTitle(); | ||||
|  | ||||
|     String getUploaderName(); | ||||
|  | ||||
|     long getDurationSeconds(); | ||||
|  | ||||
|     String getStreamUrl(); | ||||
|  | ||||
|     String getThumbnailUrl(); | ||||
|  | ||||
|     String getUploaderUrl(); | ||||
|  | ||||
|     StreamType getStreamType(); | ||||
|  | ||||
|     @NonNull | ||||
|     default Optional<StreamInfo> getMaybeStreamInfo() { | ||||
|         return Optional.empty(); | ||||
|     } | ||||
|  | ||||
|     @NonNull | ||||
|     default Optional<Quality> getMaybeQuality() { | ||||
|         return Optional.empty(); | ||||
|     } | ||||
|  | ||||
|     <T> Optional<T> getMaybeExtras(@NonNull Class<T> type); | ||||
|  | ||||
|     <T> MediaItemTag withExtras(@NonNull T extra); | ||||
|  | ||||
|     @NonNull | ||||
|     static Optional<MediaItemTag> 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<VideoStream> sortedVideoStreams; | ||||
|         private final int selectedVideoStreamIndex; | ||||
|  | ||||
|         private Quality(@NonNull final List<VideoStream> sortedVideoStreams, | ||||
|                         final int selectedVideoStreamIndex) { | ||||
|             this.sortedVideoStreams = sortedVideoStreams; | ||||
|             this.selectedVideoStreamIndex = selectedVideoStreamIndex; | ||||
|         } | ||||
|  | ||||
|         static Quality of(@NonNull final List<VideoStream> sortedVideoStreams, | ||||
|                           final int selectedVideoStreamIndex) { | ||||
|             return new Quality(sortedVideoStreams, selectedVideoStreamIndex); | ||||
|         } | ||||
|  | ||||
|         @NonNull | ||||
|         public List<VideoStream> getSortedVideoStreams() { | ||||
|             return sortedVideoStreams; | ||||
|         } | ||||
|  | ||||
|         public int getSelectedVideoStreamIndex() { | ||||
|             return selectedVideoStreamIndex; | ||||
|         } | ||||
|  | ||||
|         @Nullable | ||||
|         public VideoStream getSelectedVideoStream() { | ||||
|             return selectedVideoStreamIndex < 0 | ||||
|                     || selectedVideoStreamIndex >= sortedVideoStreams.size() | ||||
|                     ? null : sortedVideoStreams.get(selectedVideoStreamIndex); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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<Exception> 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 <T> Optional<T> getMaybeExtras(@NonNull final Class<T> type) { | ||||
|         return Optional.ofNullable(extras).map(type::cast); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public <T> MediaItemTag withExtras(@NonNull final T extra) { | ||||
|         return new PlaceholderTag(extra); | ||||
|     } | ||||
| } | ||||
| @@ -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<VideoStream> 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<Exception> 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<StreamInfo> getMaybeStreamInfo() { | ||||
|         return Optional.of(streamInfo); | ||||
|     } | ||||
|  | ||||
|     @NonNull | ||||
|     @Override | ||||
|     public Optional<Quality> getMaybeQuality() { | ||||
|         return Optional.ofNullable(quality); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public <T> Optional<T> getMaybeExtras(@NonNull final Class<T> type) { | ||||
|         return Optional.ofNullable(extras).map(type::cast); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public StreamInfoTag withExtras(@NonNull final Object extra) { | ||||
|         return new StreamInfoTag(streamInfo, quality, extra); | ||||
|     } | ||||
| } | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
| @@ -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. | ||||
|      * <br><br> | ||||
|      * 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. | ||||
|      * <br><br> | ||||
|      * 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); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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<Integer> 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. | ||||
|      * <br><br> | ||||
|      * 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 | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -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<Void> 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) { | ||||
|   | ||||
| @@ -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}. | ||||
|  * <p> | ||||
|  * This is a hack and should be removed once ExoPlayer fixes language normalization to accept | ||||
|  * a broader set of languages. | ||||
|  * </p> | ||||
|  */ | ||||
| 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<ExoTrackSelection.Definition, TextTrackScore> 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)); | ||||
|     } | ||||
| } | ||||
| @@ -11,11 +11,12 @@ import com.google.android.exoplayer2.source.MediaSource; | ||||
|  | ||||
| import org.reactivestreams.Subscriber; | ||||
| import org.reactivestreams.Subscription; | ||||
| import org.schabi.newpipe.extractor.exceptions.ExtractionException; | ||||
| import org.schabi.newpipe.player.mediaitem.MediaItemTag; | ||||
| import org.schabi.newpipe.player.mediasource.FailedMediaSource; | ||||
| import org.schabi.newpipe.player.mediasource.LoadedMediaSource; | ||||
| import org.schabi.newpipe.player.mediasource.ManagedMediaSource; | ||||
| import org.schabi.newpipe.player.mediasource.ManagedMediaSourcePlaylist; | ||||
| import org.schabi.newpipe.player.mediasource.PlaceholderMediaSource; | ||||
| import org.schabi.newpipe.player.playqueue.PlayQueue; | ||||
| import org.schabi.newpipe.player.playqueue.PlayQueueItem; | ||||
| import org.schabi.newpipe.player.playqueue.events.MoveEvent; | ||||
| @@ -195,7 +196,7 @@ public class MediaSourceManager { | ||||
|     //////////////////////////////////////////////////////////////////////////*/ | ||||
|  | ||||
|     private Subscriber<PlayQueueEvent> getReactor() { | ||||
|         return new Subscriber<PlayQueueEvent>() { | ||||
|         return new Subscriber<>() { | ||||
|             @Override | ||||
|             public void onSubscribe(@NonNull final Subscription d) { | ||||
|                 playQueueReactor.cancel(); | ||||
| @@ -209,10 +210,12 @@ public class MediaSourceManager { | ||||
|             } | ||||
|  | ||||
|             @Override | ||||
|             public void onError(@NonNull final Throwable e) { } | ||||
|             public void onError(@NonNull final Throwable e) { | ||||
|             } | ||||
|  | ||||
|             @Override | ||||
|             public void onComplete() { } | ||||
|             public void onComplete() { | ||||
|             } | ||||
|         }; | ||||
|     } | ||||
|  | ||||
| @@ -292,11 +295,11 @@ public class MediaSourceManager { | ||||
|         } | ||||
|  | ||||
|         final ManagedMediaSource mediaSource = playlist.get(playQueue.getIndex()); | ||||
|         if (mediaSource == null) { | ||||
|         final PlayQueueItem playQueueItem = playQueue.getItem(); | ||||
|         if (mediaSource == null || playQueueItem == null) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         final PlayQueueItem playQueueItem = playQueue.getItem(); | ||||
|         return mediaSource.isStreamEqual(playQueueItem); | ||||
|     } | ||||
|  | ||||
| @@ -315,7 +318,7 @@ public class MediaSourceManager { | ||||
|         isBlocked.set(true); | ||||
|     } | ||||
|  | ||||
|     private void maybeUnblock() { | ||||
|     private boolean maybeUnblock() { | ||||
|         if (DEBUG) { | ||||
|             Log.d(TAG, "maybeUnblock() called."); | ||||
|         } | ||||
| @@ -323,14 +326,17 @@ public class MediaSourceManager { | ||||
|         if (isBlocked.get()) { | ||||
|             isBlocked.set(false); | ||||
|             playbackListener.onPlaybackUnblock(playlist.getParentMediaSource()); | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     /*////////////////////////////////////////////////////////////////////////// | ||||
|     // Metadata Synchronization | ||||
|     //////////////////////////////////////////////////////////////////////////*/ | ||||
|  | ||||
|     private void maybeSync() { | ||||
|     private void maybeSync(final boolean wasBlocked) { | ||||
|         if (DEBUG) { | ||||
|             Log.d(TAG, "maybeSync() called."); | ||||
|         } | ||||
| @@ -340,13 +346,13 @@ public class MediaSourceManager { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         playbackListener.onPlaybackSynchronize(currentItem); | ||||
|         playbackListener.onPlaybackSynchronize(currentItem, wasBlocked); | ||||
|     } | ||||
|  | ||||
|     private synchronized void maybeSynchronizePlayer() { | ||||
|         if (isPlayQueueReady() && isPlaybackReady()) { | ||||
|             maybeUnblock(); | ||||
|             maybeSync(); | ||||
|             final boolean isBlockReleased = maybeUnblock(); | ||||
|             maybeSync(isBlockReleased); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -417,20 +423,29 @@ public class MediaSourceManager { | ||||
|     private Single<ManagedMediaSource> getLoadedMediaSource(@NonNull final PlayQueueItem stream) { | ||||
|         return stream.getStream().map(streamInfo -> { | ||||
|             final MediaSource source = playbackListener.sourceOf(stream, streamInfo); | ||||
|             if (source == null) { | ||||
|             if (source == null || !MediaItemTag.from(source.getMediaItem()).isPresent()) { | ||||
|                 final String message = "Unable to resolve source from stream info. " | ||||
|                         + "URL: " + stream.getUrl() + ", " | ||||
|                         + "audio count: " + streamInfo.getAudioStreams().size() + ", " | ||||
|                         + "video count: " + streamInfo.getVideoOnlyStreams().size() + ", " | ||||
|                         + streamInfo.getVideoStreams().size(); | ||||
|                 return new FailedMediaSource(stream, new MediaSourceResolutionException(message)); | ||||
|                 return (ManagedMediaSource) | ||||
|                         FailedMediaSource.of(stream, new MediaSourceResolutionException(message)); | ||||
|             } | ||||
|  | ||||
|             final MediaItemTag tag = MediaItemTag.from(source.getMediaItem()).get(); | ||||
|             final long expiration = System.currentTimeMillis() | ||||
|                     + ServiceHelper.getCacheExpirationMillis(streamInfo.getServiceId()); | ||||
|             return new LoadedMediaSource(source, stream, expiration); | ||||
|         }).onErrorReturn(throwable -> new FailedMediaSource(stream, | ||||
|                 new StreamInfoLoadException(throwable))); | ||||
|             return new LoadedMediaSource(source, tag, stream, expiration); | ||||
|         }).onErrorReturn(throwable -> { | ||||
|             if (throwable instanceof ExtractionException) { | ||||
|                 return FailedMediaSource.of(stream, new StreamInfoLoadException(throwable)); | ||||
|             } | ||||
|             // Non-source related error expected here (e.g. network), | ||||
|             // should allow retry shortly after the error. | ||||
|             return FailedMediaSource.of(stream, new Exception(throwable), | ||||
|                     /*allowRetryIn=*/TimeUnit.MILLISECONDS.convert(3, TimeUnit.SECONDS)); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     private void onMediaSourceReceived(@NonNull final PlayQueueItem item, | ||||
| @@ -478,23 +493,23 @@ public class MediaSourceManager { | ||||
|  | ||||
|     /** | ||||
|      * Checks if the current playing index contains an expired {@link ManagedMediaSource}. | ||||
|      * If so, the expired source is replaced by a {@link PlaceholderMediaSource} and | ||||
|      * If so, the expired source is replaced by a dummy {@link ManagedMediaSource} and | ||||
|      * {@link #loadImmediate()} is called to reload the current item. | ||||
|      * <br><br> | ||||
|      * If not, then the media source at the current index is ready for playback, and | ||||
|      * {@link #maybeSynchronizePlayer()} is called. | ||||
|      * <br><br> | ||||
|      * 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; | ||||
|   | ||||
| @@ -51,9 +51,10 @@ public interface PlaybackListener { | ||||
|      * May be called anytime at any amount once unblock is called. | ||||
|      * </p> | ||||
|      * | ||||
|      * @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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	 Stypox
					Stypox