mirror of
				https://github.com/TeamNewPipe/NewPipe
				synced 2025-10-31 15:23:00 +00:00 
			
		
		
		
	Merge pull request #9711 from TeamNewPipe/release-0.25.0
Release v0.25.0 (992)
This commit is contained in:
		
							
								
								
									
										2
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -126,4 +126,4 @@ jobs: | ||||
|         env: | ||||
|           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}  # Needed to get PR information, if any | ||||
|           SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} | ||||
|         run: ./gradlew build sonarqube --info | ||||
|         run: ./gradlew build sonar --info | ||||
|   | ||||
							
								
								
									
										6
									
								
								.github/workflows/image-minimizer.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.github/workflows/image-minimizer.js
									
									
									
									
										vendored
									
									
								
							| @@ -55,6 +55,7 @@ module.exports = async ({github, context}) => { | ||||
|             return match; | ||||
|         } | ||||
|          | ||||
|         let probeAspectRatio = 0; | ||||
|         let shouldModify = false; | ||||
|         try { | ||||
|             console.log(`Probing ${g2}`); | ||||
| @@ -76,7 +77,8 @@ module.exports = async ({github, context}) => { | ||||
|             } | ||||
|             console.log(`Probing resulted in ${probeResult.width}x${probeResult.height}px`); | ||||
|              | ||||
|             shouldModify = probeResult.height > IMG_MAX_HEIGHT_PX && (probeResult.width / probeResult.height) < MIN_ASPECT_RATIO; | ||||
|             probeAspectRatio = probeResult.width / probeResult.height; | ||||
|             shouldModify = probeResult.height > IMG_MAX_HEIGHT_PX && probeAspectRatio < MIN_ASPECT_RATIO; | ||||
|         } catch(e) { | ||||
|             console.log('Probing failed:', e); | ||||
|             // Immediately abort | ||||
| @@ -86,7 +88,7 @@ module.exports = async ({github, context}) => { | ||||
|         if (shouldModify) { | ||||
|             wasMatchModified = true; | ||||
|             console.log(`Modifying match '${match}'`); | ||||
|             return `<img alt="${g1}" src="${g2}" height=${IMG_MAX_HEIGHT_PX} />`; | ||||
|             return `<img alt="${g1}" src="${g2}" width=${Math.min(600, (IMG_MAX_HEIGHT_PX * probeAspectRatio).toFixed(0))} />`; | ||||
|         } | ||||
|          | ||||
|         console.log(`Match '${match}' is ok/will not be modified`); | ||||
|   | ||||
| @@ -1,23 +1,27 @@ | ||||
| import com.android.tools.profgen.ArtProfileKt | ||||
| import com.android.tools.profgen.ArtProfileSerializer | ||||
| import com.android.tools.profgen.DexFile | ||||
|  | ||||
| plugins { | ||||
|     id "com.android.application" | ||||
|     id "kotlin-android" | ||||
|     id "kotlin-kapt" | ||||
|     id "kotlin-parcelize" | ||||
|     id "checkstyle" | ||||
|     id "org.sonarqube" version "3.3" | ||||
|     id "org.sonarqube" version "3.5.0.2730" | ||||
| } | ||||
|  | ||||
| android { | ||||
|     compileSdk 32 | ||||
|     compileSdk 33 | ||||
|     namespace 'org.schabi.newpipe' | ||||
|  | ||||
|     defaultConfig { | ||||
|         applicationId "org.schabi.newpipe" | ||||
|         resValue "string", "app_name", "NewPipe" | ||||
|         minSdk 21 | ||||
|         targetSdk 29 | ||||
|         versionCode 991 | ||||
|         versionName "0.24.1" | ||||
|         targetSdk 33 | ||||
|         versionCode 992 | ||||
|         versionName "0.25.0" | ||||
|  | ||||
|         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" | ||||
|  | ||||
| @@ -107,7 +111,7 @@ ext { | ||||
|     groupieVersion = '2.10.1' | ||||
|     markwonVersion = '4.6.2' | ||||
|  | ||||
|     leakCanaryVersion = '2.5' | ||||
|     leakCanaryVersion = '2.9.1' | ||||
|     stethoVersion = '1.6.0' | ||||
|     mockitoVersion = '4.0.0' | ||||
|     assertJVersion = '3.23.1' | ||||
| @@ -169,7 +173,7 @@ afterEvaluate { | ||||
|     preDebugBuild.dependsOn runCheckstyle, runKtlint | ||||
| } | ||||
|  | ||||
| sonarqube { | ||||
| sonar { | ||||
|     properties { | ||||
|         property "sonar.projectKey", "TeamNewPipe_NewPipe" | ||||
|         property "sonar.organization", "teamnewpipe" | ||||
| @@ -179,7 +183,7 @@ sonarqube { | ||||
|  | ||||
| dependencies { | ||||
| /** Desugaring **/ | ||||
|     coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.6' | ||||
|     coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.2' | ||||
|  | ||||
| /** NewPipe libraries **/ | ||||
|     // You can use a local version by uncommenting a few lines in settings.gradle | ||||
| @@ -187,7 +191,7 @@ 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:eb07d70a2ce03bee3cc74fc33b2e4173e1c21436' | ||||
|     implementation 'com.github.TeamNewPipe:NewPipeExtractor:7e793c11aec46358ccbfd8bcfcf521105f4f093a' | ||||
|     implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0' | ||||
|  | ||||
| /** Checkstyle **/ | ||||
| @@ -259,14 +263,14 @@ dependencies { | ||||
|     implementation "io.noties.markwon:linkify:${markwonVersion}" | ||||
|  | ||||
|     // Crash reporting | ||||
|     implementation "ch.acra:acra-core:5.9.3" | ||||
|     implementation "ch.acra:acra-core:5.9.7" | ||||
|  | ||||
|     // Properly restarting | ||||
|     implementation 'com.jakewharton:process-phoenix:2.1.2' | ||||
|  | ||||
|     // Reactive extensions for Java VM | ||||
|     implementation "io.reactivex.rxjava3:rxjava:3.0.13" | ||||
|     implementation "io.reactivex.rxjava3:rxandroid:3.0.0" | ||||
|     implementation "io.reactivex.rxjava3:rxjava:3.1.5" | ||||
|     implementation "io.reactivex.rxjava3:rxandroid:3.0.2" | ||||
|     // RxJava binding APIs for Android UI widgets | ||||
|     implementation "com.jakewharton.rxbinding4:rxbinding:4.0.0" | ||||
|  | ||||
| @@ -308,3 +312,24 @@ static String getGitWorkingBranch() { | ||||
|         return "" | ||||
|     } | ||||
| } | ||||
|  | ||||
| project.afterEvaluate { | ||||
|     tasks.compileReleaseArtProfile.doLast { | ||||
|         outputs.files.each { file -> | ||||
|             if (file.toString().endsWith(".profm")) { | ||||
|                 println("Sorting ${file} ...") | ||||
|                 def version = ArtProfileSerializer.valueOf("METADATA_0_0_2") | ||||
|                 def profile = ArtProfileKt.ArtProfile(file) | ||||
|                 def keys = new ArrayList(profile.profileData.keySet()) | ||||
|                 def sortedData = new LinkedHashMap() | ||||
|                 Collections.sort keys, new DexFile.Companion() | ||||
|                 keys.each { key -> sortedData[key] = profile.profileData[key] } | ||||
|                 new FileOutputStream(file).with { | ||||
|                     write(version.magicBytes$profgen) | ||||
|                     write(version.versionBytes$profgen) | ||||
|                     version.write$profgen(it, sortedData, "") | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										737
									
								
								app/schemas/org.schabi.newpipe.database.AppDatabase/6.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										737
									
								
								app/schemas/org.schabi.newpipe.database.AppDatabase/6.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,737 @@ | ||||
| { | ||||
|   "formatVersion": 1, | ||||
|   "database": { | ||||
|     "version": 6, | ||||
|     "identityHash": "4084aa342aef315dc7b558770a7755a9", | ||||
|     "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" | ||||
|             ], | ||||
|             "orders": [], | ||||
|             "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}` (`creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", | ||||
|         "fields": [ | ||||
|           { | ||||
|             "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 | ||||
|           }, | ||||
|           { | ||||
|             "fieldPath": "id", | ||||
|             "columnName": "id", | ||||
|             "affinity": "INTEGER", | ||||
|             "notNull": true | ||||
|           } | ||||
|         ], | ||||
|         "primaryKey": { | ||||
|           "columnNames": [ | ||||
|             "id" | ||||
|           ], | ||||
|           "autoGenerate": true | ||||
|         }, | ||||
|         "indices": [ | ||||
|           { | ||||
|             "name": "index_search_history_search", | ||||
|             "unique": false, | ||||
|             "columnNames": [ | ||||
|               "search" | ||||
|             ], | ||||
|             "orders": [], | ||||
|             "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" | ||||
|             ], | ||||
|             "orders": [], | ||||
|             "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" | ||||
|             ], | ||||
|             "orders": [], | ||||
|             "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, `is_thumbnail_permanent` INTEGER NOT NULL)", | ||||
|         "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 | ||||
|           }, | ||||
|           { | ||||
|             "fieldPath": "isThumbnailPermanent", | ||||
|             "columnName": "is_thumbnail_permanent", | ||||
|             "affinity": "INTEGER", | ||||
|             "notNull": true | ||||
|           } | ||||
|         ], | ||||
|         "primaryKey": { | ||||
|           "columnNames": [ | ||||
|             "uid" | ||||
|           ], | ||||
|           "autoGenerate": true | ||||
|         }, | ||||
|         "indices": [ | ||||
|           { | ||||
|             "name": "index_playlists_name", | ||||
|             "unique": false, | ||||
|             "columnNames": [ | ||||
|               "name" | ||||
|             ], | ||||
|             "orders": [], | ||||
|             "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" | ||||
|             ], | ||||
|             "orders": [], | ||||
|             "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" | ||||
|             ], | ||||
|             "orders": [], | ||||
|             "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" | ||||
|             ], | ||||
|             "orders": [], | ||||
|             "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" | ||||
|             ], | ||||
|             "orders": [], | ||||
|             "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" | ||||
|             ], | ||||
|             "orders": [], | ||||
|             "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" | ||||
|             ], | ||||
|             "orders": [], | ||||
|             "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" | ||||
|             ], | ||||
|             "orders": [], | ||||
|             "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, '4084aa342aef315dc7b558770a7755a9')" | ||||
|     ] | ||||
|   } | ||||
| } | ||||
| @@ -33,7 +33,8 @@ class DatabaseMigrationTest { | ||||
|     @get:Rule | ||||
|     val testHelper = MigrationTestHelper( | ||||
|         InstrumentationRegistry.getInstrumentation(), | ||||
|         AppDatabase::class.java.canonicalName, FrameworkSQLiteOpenHelperFactory() | ||||
|         AppDatabase::class.java.canonicalName, | ||||
|         FrameworkSQLiteOpenHelperFactory() | ||||
|     ) | ||||
|  | ||||
|     @Test | ||||
| @@ -42,7 +43,8 @@ class DatabaseMigrationTest { | ||||
|  | ||||
|         databaseInV2.run { | ||||
|             insert( | ||||
|                 "streams", SQLiteDatabase.CONFLICT_FAIL, | ||||
|                 "streams", | ||||
|                 SQLiteDatabase.CONFLICT_FAIL, | ||||
|                 ContentValues().apply { | ||||
|                     put("service_id", DEFAULT_SERVICE_ID) | ||||
|                     put("url", DEFAULT_URL) | ||||
| @@ -54,14 +56,16 @@ class DatabaseMigrationTest { | ||||
|                 } | ||||
|             ) | ||||
|             insert( | ||||
|                 "streams", SQLiteDatabase.CONFLICT_FAIL, | ||||
|                 "streams", | ||||
|                 SQLiteDatabase.CONFLICT_FAIL, | ||||
|                 ContentValues().apply { | ||||
|                     put("service_id", DEFAULT_SECOND_SERVICE_ID) | ||||
|                     put("url", DEFAULT_SECOND_URL) | ||||
|                 } | ||||
|             ) | ||||
|             insert( | ||||
|                 "streams", SQLiteDatabase.CONFLICT_FAIL, | ||||
|                 "streams", | ||||
|                 SQLiteDatabase.CONFLICT_FAIL, | ||||
|                 ContentValues().apply { | ||||
|                     put("service_id", DEFAULT_SERVICE_ID) | ||||
|                 } | ||||
| @@ -70,18 +74,31 @@ class DatabaseMigrationTest { | ||||
|         } | ||||
|  | ||||
|         testHelper.runMigrationsAndValidate( | ||||
|             AppDatabase.DATABASE_NAME, Migrations.DB_VER_3, | ||||
|             true, Migrations.MIGRATION_2_3 | ||||
|             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 | ||||
|             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 | ||||
|             AppDatabase.DATABASE_NAME, | ||||
|             Migrations.DB_VER_5, | ||||
|             true, | ||||
|             Migrations.MIGRATION_4_5 | ||||
|         ) | ||||
|  | ||||
|         testHelper.runMigrationsAndValidate( | ||||
|             AppDatabase.DATABASE_NAME, | ||||
|             Migrations.DB_VER_6, | ||||
|             true, | ||||
|             Migrations.MIGRATION_5_6 | ||||
|         ) | ||||
|  | ||||
|         val migratedDatabaseV3 = getMigratedDatabase() | ||||
| @@ -121,7 +138,8 @@ class DatabaseMigrationTest { | ||||
|     private fun getMigratedDatabase(): AppDatabase { | ||||
|         val database: AppDatabase = Room.databaseBuilder( | ||||
|             ApplicationProvider.getApplicationContext(), | ||||
|             AppDatabase::class.java, AppDatabase.DATABASE_NAME | ||||
|             AppDatabase::class.java, | ||||
|             AppDatabase.DATABASE_NAME | ||||
|         ) | ||||
|             .build() | ||||
|         testHelper.closeWhenFinished(database) | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| 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.collection.SparseArrayCompat | ||||
| import androidx.test.core.app.ApplicationProvider | ||||
| import androidx.test.ext.junit.runners.AndroidJUnit4 | ||||
| import androidx.test.filters.MediumTest | ||||
| @@ -39,9 +39,7 @@ class StreamItemAdapterTest { | ||||
|     @Test | ||||
|     fun videoStreams_noSecondaryStream() { | ||||
|         val adapter = StreamItemAdapter<VideoStream, AudioStream>( | ||||
|             context, | ||||
|             getVideoStreams(true, true, true, true), | ||||
|             null | ||||
|             getVideoStreams(true, true, true, true) | ||||
|         ) | ||||
|  | ||||
|         spinner.adapter = adapter | ||||
| @@ -54,7 +52,6 @@ class StreamItemAdapterTest { | ||||
|     @Test | ||||
|     fun videoStreams_hasSecondaryStream() { | ||||
|         val adapter = StreamItemAdapter( | ||||
|             context, | ||||
|             getVideoStreams(false, true, false, true), | ||||
|             getAudioStreams(false, true, false, true) | ||||
|         ) | ||||
| @@ -69,7 +66,6 @@ class StreamItemAdapterTest { | ||||
|     @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) | ||||
|         ) | ||||
| @@ -88,7 +84,6 @@ class StreamItemAdapterTest { | ||||
|     @Test | ||||
|     fun subtitleStreams_noIcon() { | ||||
|         val adapter = StreamItemAdapter<SubtitlesStream, Stream>( | ||||
|             context, | ||||
|             StreamItemAdapter.StreamSizeWrapper( | ||||
|                 (0 until 5).map { | ||||
|                     SubtitlesStream.Builder() | ||||
| @@ -99,8 +94,7 @@ class StreamItemAdapterTest { | ||||
|                         .build() | ||||
|                 }, | ||||
|                 context | ||||
|             ), | ||||
|             null | ||||
|             ) | ||||
|         ) | ||||
|         spinner.adapter = adapter | ||||
|         for (i in 0 until spinner.count) { | ||||
| @@ -111,7 +105,6 @@ class StreamItemAdapterTest { | ||||
|     @Test | ||||
|     fun audioStreams_noIcon() { | ||||
|         val adapter = StreamItemAdapter<AudioStream, Stream>( | ||||
|             context, | ||||
|             StreamItemAdapter.StreamSizeWrapper( | ||||
|                 (0 until 5).map { | ||||
|                     AudioStream.Builder() | ||||
| @@ -122,8 +115,7 @@ class StreamItemAdapterTest { | ||||
|                         .build() | ||||
|                 }, | ||||
|                 context | ||||
|             ), | ||||
|             null | ||||
|             ) | ||||
|         ) | ||||
|         spinner.adapter = adapter | ||||
|         for (i in 0 until spinner.count) { | ||||
| @@ -200,7 +192,7 @@ class StreamItemAdapterTest { | ||||
|      * Helper function that builds a secondary stream list. | ||||
|      */ | ||||
|     private fun <T : Stream> getSecondaryStreamsFromList(streams: List<T?>) = | ||||
|         SparseArray<SecondaryStreamHelper<T>?>(streams.size).apply { | ||||
|         SparseArrayCompat<SecondaryStreamHelper<T>?>(streams.size).apply { | ||||
|             streams.forEachIndexed { index, stream -> | ||||
|                 val secondaryStreamHelper: SecondaryStreamHelper<T>? = stream?.let { | ||||
|                     SecondaryStreamHelper( | ||||
|   | ||||
| @@ -9,6 +9,15 @@ | ||||
|     <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> | ||||
|     <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> | ||||
|     <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> | ||||
|     <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> | ||||
|  | ||||
|     <!-- We need to be able to open links in the browser on API 30+ --> | ||||
|     <queries> | ||||
|         <intent> | ||||
|             <action android:name="android.intent.action.VIEW" /> | ||||
|             <data android:scheme="http|https|market" /> | ||||
|         </intent> | ||||
|     </queries> | ||||
|  | ||||
|     <uses-feature | ||||
|         android:name="android.hardware.touchscreen" | ||||
| @@ -24,11 +33,12 @@ | ||||
|         android:icon="@mipmap/ic_launcher" | ||||
|         android:label="@string/app_name" | ||||
|         android:logo="@mipmap/ic_launcher" | ||||
|         android:theme="@style/OpeningTheme" | ||||
|         android:resizeableActivity="true" | ||||
|         android:theme="@style/OpeningTheme" | ||||
|         tools:ignore="AllowBackup"> | ||||
|         <activity | ||||
|             android:name=".MainActivity" | ||||
|             android:exported="true" | ||||
|             android:label="@string/app_name" | ||||
|             android:launchMode="singleTask"> | ||||
|             <intent-filter> | ||||
| @@ -39,7 +49,9 @@ | ||||
|             </intent-filter> | ||||
|         </activity> | ||||
|  | ||||
|         <receiver android:name="androidx.media.session.MediaButtonReceiver"> | ||||
|         <receiver | ||||
|             android:name="androidx.media.session.MediaButtonReceiver" | ||||
|             android:exported="true"> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.MEDIA_BUTTON" /> | ||||
|             </intent-filter> | ||||
| @@ -47,7 +59,7 @@ | ||||
|  | ||||
|         <service | ||||
|             android:name=".player.PlayerService" | ||||
|             android:exported="false" | ||||
|             android:exported="true" | ||||
|             android:foregroundServiceType="mediaPlayback"> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.MEDIA_BUTTON" /> | ||||
| @@ -56,15 +68,18 @@ | ||||
|  | ||||
|         <activity | ||||
|             android:name=".player.PlayQueueActivity" | ||||
|             android:exported="false" | ||||
|             android:label="@string/title_activity_play_queue" | ||||
|             android:launchMode="singleTask" /> | ||||
|  | ||||
|         <activity | ||||
|             android:name=".settings.SettingsActivity" | ||||
|             android:exported="false" | ||||
|             android:label="@string/settings" /> | ||||
|  | ||||
|         <activity | ||||
|             android:name=".about.AboutActivity" | ||||
|             android:exported="false" | ||||
|             android:label="@string/title_activity_about" /> | ||||
|  | ||||
|         <service android:name=".local.subscription.services.SubscriptionsImportService" /> | ||||
| @@ -73,6 +88,7 @@ | ||||
|  | ||||
|         <activity | ||||
|             android:name=".PanicResponderActivity" | ||||
|             android:exported="true" | ||||
|             android:launchMode="singleInstance" | ||||
|             android:noHistory="true" | ||||
|             android:theme="@android:style/Theme.NoDisplay"> | ||||
| @@ -85,13 +101,18 @@ | ||||
|  | ||||
|         <activity | ||||
|             android:name=".ExitActivity" | ||||
|             android:exported="false" | ||||
|             android:label="@string/general_error" | ||||
|             android:theme="@android:style/Theme.NoDisplay" /> | ||||
|         <activity android:name=".error.ErrorActivity" /> | ||||
|  | ||||
|         <activity | ||||
|             android:name=".error.ErrorActivity" | ||||
|             android:exported="false" /> | ||||
|  | ||||
|         <!-- giga get related --> | ||||
|         <activity | ||||
|             android:name=".download.DownloadActivity" | ||||
|             android:exported="false" | ||||
|             android:label="@string/app_name" | ||||
|             android:launchMode="singleTask" /> | ||||
|  | ||||
| @@ -99,6 +120,7 @@ | ||||
|  | ||||
|         <activity | ||||
|             android:name=".util.FilePickerActivityHelper" | ||||
|             android:exported="true" | ||||
|             android:label="@string/app_name" | ||||
|             android:theme="@style/FilePickerThemeDark"> | ||||
|             <intent-filter> | ||||
| @@ -109,6 +131,7 @@ | ||||
|  | ||||
|         <activity | ||||
|             android:name=".error.ReCaptchaActivity" | ||||
|             android:exported="false" | ||||
|             android:label="@string/recaptcha" /> | ||||
|  | ||||
|         <provider | ||||
| @@ -124,6 +147,7 @@ | ||||
|         <activity | ||||
|             android:name=".RouterActivity" | ||||
|             android:excludeFromRecents="true" | ||||
|             android:exported="true" | ||||
|             android:label="@string/preferred_open_action_share_menu_title" | ||||
|             android:taskAffinity="" | ||||
|             android:theme="@style/RouterActivityThemeDark"> | ||||
| @@ -149,6 +173,7 @@ | ||||
|                 <data android:pathPrefix="/watch" /> | ||||
|                 <data android:pathPrefix="/attribution_link" /> | ||||
|                 <data android:pathPrefix="/shorts/" /> | ||||
|                 <data android:pathPrefix="/live/" /> | ||||
|                 <!-- channel prefix --> | ||||
|                 <data android:pathPrefix="/channel/" /> | ||||
|                 <data android:pathPrefix="/user/" /> | ||||
| @@ -337,7 +362,6 @@ | ||||
|                 <data android:host="peertube.mastodon.host" /> | ||||
|                 <data android:host="peertube.fr" /> | ||||
|                 <data android:host="tilvids.com" /> | ||||
|                 <data android:host="tube.privacytools.io" /> | ||||
|                 <data android:host="video.ploud.fr" /> | ||||
|                 <data android:host="video.lqdn.fr" /> | ||||
|                 <data android:host="skeptikon.fr" /> | ||||
| @@ -354,30 +378,30 @@ | ||||
|  | ||||
|             <!-- Bandcamp filter for tracks, albums and playlists --> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.VIEW"/> | ||||
|                 <action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH"/> | ||||
|                 <action android:name="android.nfc.action.NDEF_DISCOVERED"/> | ||||
|                 <action android:name="android.intent.action.VIEW" /> | ||||
|                 <action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" /> | ||||
|                 <action android:name="android.nfc.action.NDEF_DISCOVERED" /> | ||||
|  | ||||
|                 <category android:name="android.intent.category.DEFAULT"/> | ||||
|                 <category android:name="android.intent.category.BROWSABLE"/> | ||||
|                 <category android:name="android.intent.category.DEFAULT" /> | ||||
|                 <category android:name="android.intent.category.BROWSABLE" /> | ||||
|  | ||||
|                 <data android:scheme="http"/> | ||||
|                 <data android:scheme="https"/> | ||||
|                 <data android:host="*.bandcamp.com"/> | ||||
|                 <data android:scheme="http" /> | ||||
|                 <data android:scheme="https" /> | ||||
|                 <data android:host="*.bandcamp.com" /> | ||||
|             </intent-filter> | ||||
|  | ||||
|             <!-- Bandcamp filter for radio --> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.VIEW"/> | ||||
|                 <action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH"/> | ||||
|                 <action android:name="android.nfc.action.NDEF_DISCOVERED"/> | ||||
|                 <action android:name="android.intent.action.VIEW" /> | ||||
|                 <action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" /> | ||||
|                 <action android:name="android.nfc.action.NDEF_DISCOVERED" /> | ||||
|  | ||||
|                 <category android:name="android.intent.category.DEFAULT"/> | ||||
|                 <category android:name="android.intent.category.BROWSABLE"/> | ||||
|                 <category android:name="android.intent.category.DEFAULT" /> | ||||
|                 <category android:name="android.intent.category.BROWSABLE" /> | ||||
|  | ||||
|                 <data android:scheme="http"/> | ||||
|                 <data android:scheme="https"/> | ||||
|                 <data android:sspPattern="bandcamp.com/?show=*"/> | ||||
|                 <data android:scheme="http" /> | ||||
|                 <data android:scheme="https" /> | ||||
|                 <data android:sspPattern="bandcamp.com/?show=*" /> | ||||
|             </intent-filter> | ||||
|  | ||||
|         </activity> | ||||
| @@ -386,11 +410,17 @@ | ||||
|             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" /> | ||||
|         <meta-data | ||||
|             android:name="android.webkit.WebView.MetricsOptOut" | ||||
|             android:value="true" /> | ||||
|         <!-- see https://github.com/TeamNewPipe/NewPipe/issues/3947 --> | ||||
|         <!-- Version < 3.0. DeX Mode and Screen Mirroring support --> | ||||
|         <meta-data android:name="com.samsung.android.keepalive.density" android:value="true"/> | ||||
|         <meta-data | ||||
|             android:name="com.samsung.android.keepalive.density" | ||||
|             android:value="true" /> | ||||
|         <!-- Version >= 3.0. DeX Dual Mode support --> | ||||
|         <meta-data android:name="com.samsung.android.multidisplay.keep_process_alive" android:value="true"/> | ||||
|         <meta-data | ||||
|             android:name="com.samsung.android.multidisplay.keep_process_alive" | ||||
|             android:value="true" /> | ||||
|     </application> | ||||
| </manifest> | ||||
|   | ||||
| @@ -157,9 +157,12 @@ public class MainActivity extends AppCompatActivity { | ||||
|         } | ||||
|         openMiniPlayerUponPlayerStarted(); | ||||
|  | ||||
|         // Schedule worker for checking for new streams and creating corresponding notifications | ||||
|         // if this is enabled by the user. | ||||
|         NotificationWorker.initialize(this); | ||||
|         if (PermissionHelper.checkPostNotificationsPermission(this, | ||||
|                 PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE)) { | ||||
|             // Schedule worker for checking for new streams and creating corresponding notifications | ||||
|             // if this is enabled by the user. | ||||
|             NotificationWorker.initialize(this); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
| @@ -172,7 +175,7 @@ public class MainActivity extends AppCompatActivity { | ||||
|         if (prefs.getBoolean(app.getString(R.string.update_app_key), true)) { | ||||
|             // Start the worker which is checking all conditions | ||||
|             // and eventually searching for a new version. | ||||
|             NewVersionWorker.enqueueNewVersionCheckingWork(app); | ||||
|             NewVersionWorker.enqueueNewVersionCheckingWork(app, false); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -232,7 +235,7 @@ public class MainActivity extends AppCompatActivity { | ||||
|                 .setIcon(R.drawable.ic_tv); | ||||
|         drawerLayoutBinding.navigation.getMenu() | ||||
|                 .add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title) | ||||
|                 .setIcon(R.drawable.ic_rss_feed); | ||||
|                 .setIcon(R.drawable.ic_subscriptions); | ||||
|         drawerLayoutBinding.navigation.getMenu() | ||||
|                 .add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks) | ||||
|                 .setIcon(R.drawable.ic_bookmark); | ||||
| @@ -599,6 +602,9 @@ public class MainActivity extends AppCompatActivity { | ||||
|                     ((VideoDetailFragment) fragment).openDownloadDialog(); | ||||
|                 } | ||||
|                 break; | ||||
|             case PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE: | ||||
|                 NotificationWorker.initialize(this); | ||||
|                 break; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -5,6 +5,7 @@ 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 static org.schabi.newpipe.database.Migrations.MIGRATION_5_6; | ||||
|  | ||||
| import android.content.Context; | ||||
| import android.database.Cursor; | ||||
| @@ -24,7 +25,8 @@ 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, MIGRATION_4_5) | ||||
|                 .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, | ||||
|                         MIGRATION_5_6) | ||||
|                 .build(); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1,23 +1,25 @@ | ||||
| package org.schabi.newpipe | ||||
|  | ||||
| import android.app.PendingIntent | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import android.util.Log | ||||
| import android.widget.Toast | ||||
| import androidx.core.app.NotificationCompat | ||||
| import androidx.core.app.NotificationManagerCompat | ||||
| import androidx.core.content.ContextCompat | ||||
| import androidx.core.content.edit | ||||
| import androidx.core.net.toUri | ||||
| import androidx.preference.PreferenceManager | ||||
| import androidx.work.OneTimeWorkRequest | ||||
| import androidx.work.OneTimeWorkRequestBuilder | ||||
| import androidx.work.WorkManager | ||||
| import androidx.work.WorkRequest | ||||
| import androidx.work.Worker | ||||
| import androidx.work.WorkerParameters | ||||
| import androidx.work.workDataOf | ||||
| 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.PendingIntentCompat | ||||
| import org.schabi.newpipe.util.ReleaseVersionUtil.coerceUpdateCheckExpiry | ||||
| import org.schabi.newpipe.util.ReleaseVersionUtil.isLastUpdateCheckExpired | ||||
| import org.schabi.newpipe.util.ReleaseVersionUtil.isReleaseApk | ||||
| @@ -42,26 +44,40 @@ class NewVersionWorker( | ||||
|         versionCode: Int | ||||
|     ) { | ||||
|         if (BuildConfig.VERSION_CODE >= versionCode) { | ||||
|             if (inputData.getBoolean(IS_MANUAL, false)) { | ||||
|                 // Show toast stating that the app is up-to-date if the update check was manual. | ||||
|                 ContextCompat.getMainExecutor(applicationContext).execute { | ||||
|                     Toast.makeText( | ||||
|                         applicationContext, R.string.app_update_unavailable_toast, | ||||
|                         Toast.LENGTH_SHORT | ||||
|                     ).show() | ||||
|                 } | ||||
|             } | ||||
|             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) | ||||
|         val pendingIntent = PendingIntentCompat.getActivity( | ||||
|             applicationContext, 0, intent, 0 | ||||
|         ) | ||||
|         val channelId = applicationContext.getString(R.string.app_update_notification_channel_id) | ||||
|         val notificationBuilder = NotificationCompat.Builder(applicationContext, 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 | ||||
|             .setContentIntent(pendingIntent) | ||||
|             .setContentTitle( | ||||
|                 applicationContext.getString(R.string.app_update_available_notification_title) | ||||
|             ) | ||||
|         val notificationManager = NotificationManagerCompat.from(app) | ||||
|             .setContentText( | ||||
|                 applicationContext.getString( | ||||
|                     R.string.app_update_available_notification_text, versionName | ||||
|                 ) | ||||
|             ) | ||||
|  | ||||
|         val notificationManager = NotificationManagerCompat.from(applicationContext) | ||||
|         notificationManager.notify(2000, notificationBuilder.build()) | ||||
|     } | ||||
|  | ||||
| @@ -72,12 +88,14 @@ class NewVersionWorker( | ||||
|             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 | ||||
|         if (!inputData.getBoolean(IS_MANUAL, false)) { | ||||
|             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. | ||||
| @@ -120,43 +138,42 @@ class NewVersionWorker( | ||||
|     } | ||||
|  | ||||
|     override fun doWork(): Result { | ||||
|         try { | ||||
|         return try { | ||||
|             checkNewVersion() | ||||
|             Result.success() | ||||
|         } catch (e: IOException) { | ||||
|             Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e) | ||||
|             return Result.failure() | ||||
|             Result.failure() | ||||
|         } catch (e: ReCaptchaException) { | ||||
|             Log.e(TAG, "ReCaptchaException should never happen here.", e) | ||||
|             return Result.failure() | ||||
|             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" | ||||
|         private const val IS_MANUAL = "isManual" | ||||
|  | ||||
|         /** | ||||
|          * 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. | ||||
|          * 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 an available update if one is available. | ||||
|          * <br></br> | ||||
|          * Following conditions need to be met, before data is request from the server: | ||||
|          * Following conditions need to be met, before data is requested 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() | ||||
|         fun enqueueNewVersionCheckingWork(context: Context, isManual: Boolean) { | ||||
|             val workRequest = OneTimeWorkRequestBuilder<NewVersionWorker>() | ||||
|                 .setInputData(workDataOf(IS_MANUAL to isManual)) | ||||
|                 .build() | ||||
|             WorkManager.getInstance(context).enqueue(workRequest) | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| package org.schabi.newpipe; | ||||
|  | ||||
| import static org.schabi.newpipe.util.SparseItemUtil.fetchStreamInfoAndSaveToDatabase; | ||||
| import static org.schabi.newpipe.util.external_communication.ShareUtils.shareText; | ||||
|  | ||||
| import android.content.Context; | ||||
| @@ -10,6 +11,7 @@ import android.widget.PopupMenu; | ||||
| import androidx.fragment.app.FragmentManager; | ||||
|  | ||||
| import org.schabi.newpipe.database.stream.model.StreamEntity; | ||||
| import org.schabi.newpipe.download.DownloadDialog; | ||||
| import org.schabi.newpipe.local.dialog.PlaylistDialog; | ||||
| import org.schabi.newpipe.player.playqueue.PlayQueue; | ||||
| import org.schabi.newpipe.player.playqueue.PlayQueueItem; | ||||
| @@ -75,6 +77,14 @@ public final class QueueItemMenuUtil { | ||||
|                     shareText(context, item.getTitle(), item.getUrl(), | ||||
|                             item.getThumbnailUrl()); | ||||
|                     return true; | ||||
|                 case R.id.menu_item_download: | ||||
|                     fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(), | ||||
|                             info -> { | ||||
|                                 final DownloadDialog downloadDialog = new DownloadDialog(context, | ||||
|                                         info); | ||||
|                                 downloadDialog.show(fragmentManager, "downloadDialog"); | ||||
|                             }); | ||||
|                     return true; | ||||
|             } | ||||
|             return false; | ||||
|         }); | ||||
|   | ||||
| @@ -10,12 +10,14 @@ import android.content.DialogInterface; | ||||
| import android.content.Intent; | ||||
| import android.content.SharedPreferences; | ||||
| import android.content.pm.PackageManager; | ||||
| import android.os.Build; | ||||
| import android.os.Bundle; | ||||
| import android.text.TextUtils; | ||||
| import android.view.ContextThemeWrapper; | ||||
| import android.view.LayoutInflater; | ||||
| import android.view.View; | ||||
| import android.view.ViewGroup; | ||||
| import android.view.WindowManager; | ||||
| import android.widget.Button; | ||||
| import android.widget.RadioButton; | ||||
| import android.widget.RadioGroup; | ||||
| @@ -31,7 +33,12 @@ import androidx.appcompat.content.res.AppCompatResources; | ||||
| import androidx.core.app.NotificationCompat; | ||||
| import androidx.core.app.ServiceCompat; | ||||
| import androidx.core.math.MathUtils; | ||||
| import androidx.fragment.app.DialogFragment; | ||||
| import androidx.fragment.app.Fragment; | ||||
| import androidx.fragment.app.FragmentManager; | ||||
| import androidx.lifecycle.DefaultLifecycleObserver; | ||||
| import androidx.lifecycle.Lifecycle; | ||||
| import androidx.lifecycle.LifecycleOwner; | ||||
| import androidx.preference.PreferenceManager; | ||||
|  | ||||
| import org.schabi.newpipe.database.stream.model.StreamEntity; | ||||
| @@ -80,9 +87,13 @@ import org.schabi.newpipe.util.urlfinder.UrlFinder; | ||||
| import org.schabi.newpipe.views.FocusOverlayView; | ||||
|  | ||||
| import java.io.Serializable; | ||||
| import java.lang.ref.Reference; | ||||
| import java.lang.ref.WeakReference; | ||||
| import java.util.ArrayList; | ||||
| import java.util.Arrays; | ||||
| import java.util.List; | ||||
| import java.util.Optional; | ||||
| import java.util.function.Consumer; | ||||
|  | ||||
| import icepick.Icepick; | ||||
| import icepick.State; | ||||
| @@ -91,7 +102,6 @@ import io.reactivex.rxjava3.core.Observable; | ||||
| import io.reactivex.rxjava3.core.Single; | ||||
| import io.reactivex.rxjava3.disposables.CompositeDisposable; | ||||
| import io.reactivex.rxjava3.disposables.Disposable; | ||||
| import io.reactivex.rxjava3.functions.Consumer; | ||||
| import io.reactivex.rxjava3.schedulers.Schedulers; | ||||
|  | ||||
| /** | ||||
| @@ -111,12 +121,57 @@ public class RouterActivity extends AppCompatActivity { | ||||
|     private boolean selectionIsDownload = false; | ||||
|     private boolean selectionIsAddToPlaylist = false; | ||||
|     private AlertDialog alertDialogChoice = null; | ||||
|     private FragmentManager.FragmentLifecycleCallbacks dismissListener = null; | ||||
|  | ||||
|     @Override | ||||
|     protected void onCreate(final Bundle savedInstanceState) { | ||||
|         ThemeHelper.setDayNightMode(this); | ||||
|         setTheme(ThemeHelper.isLightThemeSelected(this) | ||||
|                 ? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark); | ||||
|         Localization.assureCorrectAppLanguage(this); | ||||
|  | ||||
|         // Pass-through touch events to background activities | ||||
|         // so that our transparent window won't lock UI in the mean time | ||||
|         // network request is underway before showing PlaylistDialog or DownloadDialog | ||||
|         // (ref: https://stackoverflow.com/a/10606141) | ||||
|         getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | ||||
|                 | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL | ||||
|                 | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE); | ||||
|  | ||||
|         // Android never fails to impress us with a list of new restrictions per API. | ||||
|         // Starting with S (Android 12) one of the prerequisite conditions has to be met | ||||
|         // before the FLAG_NOT_TOUCHABLE flag is allowed to kick in: | ||||
|         // @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE | ||||
|         // For our present purpose it seems we can just set LayoutParams.alpha to 0 | ||||
|         // on the strength of "4. Fully transparent windows" without affecting the scrim of dialogs | ||||
|         final WindowManager.LayoutParams params = getWindow().getAttributes(); | ||||
|         params.alpha = 0f; | ||||
|         getWindow().setAttributes(params); | ||||
|  | ||||
|         super.onCreate(savedInstanceState); | ||||
|         Icepick.restoreInstanceState(this, savedInstanceState); | ||||
|  | ||||
|         // FragmentManager will take care to recreate (Playlist|Download)Dialog when screen rotates | ||||
|         // We used to .setOnDismissListener(dialog -> finish()); when creating these DialogFragments | ||||
|         // but those callbacks won't survive a config change | ||||
|         // Try an alternate approach to hook into FragmentManager instead, to that effect | ||||
|         // (ref: https://stackoverflow.com/a/44028453) | ||||
|         final FragmentManager fm = getSupportFragmentManager(); | ||||
|         if (dismissListener == null) { | ||||
|             dismissListener = new FragmentManager.FragmentLifecycleCallbacks() { | ||||
|                 @Override | ||||
|                 public void onFragmentDestroyed(@NonNull final FragmentManager fm, | ||||
|                                                 @NonNull final Fragment f) { | ||||
|                     super.onFragmentDestroyed(fm, f); | ||||
|                     if (f instanceof DialogFragment && fm.getFragments().isEmpty()) { | ||||
|                         // No more DialogFragments, we're done | ||||
|                         finish(); | ||||
|                     } | ||||
|                 } | ||||
|             }; | ||||
|         } | ||||
|         fm.registerFragmentLifecycleCallbacks(dismissListener, false); | ||||
|  | ||||
|         if (TextUtils.isEmpty(currentUrl)) { | ||||
|             currentUrl = getUrl(getIntent()); | ||||
|  | ||||
| @@ -125,11 +180,6 @@ public class RouterActivity extends AppCompatActivity { | ||||
|                 finish(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         ThemeHelper.setDayNightMode(this); | ||||
|         setTheme(ThemeHelper.isLightThemeSelected(this) | ||||
|                 ? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark); | ||||
|         Localization.assureCorrectAppLanguage(this); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
| @@ -151,16 +201,34 @@ public class RouterActivity extends AppCompatActivity { | ||||
|     protected void onStart() { | ||||
|         super.onStart(); | ||||
|  | ||||
|         handleUrl(currentUrl); | ||||
|         // Don't overlap the DialogFragment after rotating the screen | ||||
|         // If there's no DialogFragment, we're either starting afresh | ||||
|         // or we didn't make it to PlaylistDialog or DownloadDialog before the orientation change | ||||
|         if (getSupportFragmentManager().getFragments().isEmpty()) { | ||||
|             // Start over from scratch | ||||
|             handleUrl(currentUrl); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     protected void onDestroy() { | ||||
|         super.onDestroy(); | ||||
|  | ||||
|         if (dismissListener != null) { | ||||
|             getSupportFragmentManager().unregisterFragmentLifecycleCallbacks(dismissListener); | ||||
|         } | ||||
|  | ||||
|         disposables.clear(); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void finish() { | ||||
|         // allow the activity to recreate in case orientation changes | ||||
|         if (!isChangingConfigurations()) { | ||||
|             super.finish(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void handleUrl(final String url) { | ||||
|         disposables.add(Observable | ||||
|                 .fromCallable(() -> { | ||||
| @@ -240,7 +308,7 @@ public class RouterActivity extends AppCompatActivity { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void showUnsupportedUrlDialog(final String url) { | ||||
|     protected void showUnsupportedUrlDialog(final String url) { | ||||
|         final Context context = getThemeWrapperContext(); | ||||
|         new AlertDialog.Builder(context) | ||||
|                 .setTitle(R.string.unsupported_url) | ||||
| @@ -527,7 +595,7 @@ public class RouterActivity extends AppCompatActivity { | ||||
|         return returnedItems; | ||||
|     } | ||||
|  | ||||
|     private Context getThemeWrapperContext() { | ||||
|     protected Context getThemeWrapperContext() { | ||||
|         return new ContextThemeWrapper(this, ThemeHelper.isLightThemeSelected(this) | ||||
|                 ? R.style.LightTheme : R.style.DarkTheme); | ||||
|     } | ||||
| @@ -563,8 +631,7 @@ public class RouterActivity extends AppCompatActivity { | ||||
|         } | ||||
|  | ||||
|         if (selectedChoiceKey.equals(getString(R.string.popup_player_key)) | ||||
|                 && !PermissionHelper.isPopupEnabled(this)) { | ||||
|             PermissionHelper.showPopupEnablementToast(this); | ||||
|                 && !PermissionHelper.isPopupEnabledElseAsk(this)) { | ||||
|             finish(); | ||||
|             return; | ||||
|         } | ||||
| @@ -634,54 +701,179 @@ public class RouterActivity extends AppCompatActivity { | ||||
|         return playerType == null || playerType == PlayerType.MAIN; | ||||
|     } | ||||
|  | ||||
|     private void openAddToPlaylistDialog() { | ||||
|         // Getting the stream info usually takes a moment | ||||
|         // Notifying the user here to ensure that no confusion arises | ||||
|         Toast.makeText( | ||||
|                 getApplicationContext(), | ||||
|                 getString(R.string.processing_may_take_a_moment), | ||||
|                 Toast.LENGTH_SHORT) | ||||
|                 .show(); | ||||
|     public static class PersistentFragment extends Fragment { | ||||
|         private WeakReference<AppCompatActivity> weakContext; | ||||
|         private final CompositeDisposable disposables = new CompositeDisposable(); | ||||
|         private int running = 0; | ||||
|  | ||||
|         disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, false) | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribe( | ||||
|                         info -> PlaylistDialog.createCorrespondingDialog( | ||||
|                                 getThemeWrapperContext(), | ||||
|                                 List.of(new StreamEntity(info)), | ||||
|                                 playlistDialog -> { | ||||
|                                     playlistDialog.setOnDismissListener(dialog -> finish()); | ||||
|         private synchronized void inFlight(final boolean started) { | ||||
|             if (started) { | ||||
|                 running++; | ||||
|             } else { | ||||
|                 running--; | ||||
|                 if (running <= 0) { | ||||
|                     getActivityContext().ifPresent(context -> context.getSupportFragmentManager() | ||||
|                             .beginTransaction().remove(this).commit()); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|                                     playlistDialog.show( | ||||
|                                             this.getSupportFragmentManager(), | ||||
|                                             "addToPlaylistDialog" | ||||
|                                     ); | ||||
|                                 } | ||||
|                         ), | ||||
|                         throwable -> handleError(this, new ErrorInfo( | ||||
|                                 throwable, | ||||
|                                 UserAction.REQUESTED_STREAM, | ||||
|                                 "Tried to add " + currentUrl + " to a playlist", | ||||
|                                 currentService.getServiceId()) | ||||
|                         ) | ||||
|                 ) | ||||
|         ); | ||||
|         @Override | ||||
|         public void onAttach(@NonNull final Context activityContext) { | ||||
|             super.onAttach(activityContext); | ||||
|             weakContext = new WeakReference<>((AppCompatActivity) activityContext); | ||||
|         } | ||||
|  | ||||
|         @Override | ||||
|         public void onDetach() { | ||||
|             super.onDetach(); | ||||
|             weakContext = null; | ||||
|         } | ||||
|  | ||||
|         @SuppressWarnings("deprecation") | ||||
|         @Override | ||||
|         public void onCreate(final Bundle savedInstanceState) { | ||||
|             super.onCreate(savedInstanceState); | ||||
|             setRetainInstance(true); | ||||
|         } | ||||
|  | ||||
|         @Override | ||||
|         public void onDestroy() { | ||||
|             super.onDestroy(); | ||||
|             disposables.clear(); | ||||
|         } | ||||
|  | ||||
|         /** | ||||
|          * @return the activity context, if there is one and the activity is not finishing | ||||
|          */ | ||||
|         private Optional<AppCompatActivity> getActivityContext() { | ||||
|             return Optional.ofNullable(weakContext) | ||||
|                     .map(Reference::get) | ||||
|                     .filter(context -> !context.isFinishing()); | ||||
|         } | ||||
|  | ||||
|         // guard against IllegalStateException in calling DialogFragment.show() whilst in background | ||||
|         // (which could happen, say, when the user pressed the home button while waiting for | ||||
|         // the network request to return) when it internally calls FragmentTransaction.commit() | ||||
|         // after the FragmentManager has saved its states (isStateSaved() == true) | ||||
|         // (ref: https://stackoverflow.com/a/39813506) | ||||
|         private void runOnVisible(final Consumer<AppCompatActivity> runnable) { | ||||
|             getActivityContext().ifPresentOrElse(context -> { | ||||
|                 if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) { | ||||
|                     context.runOnUiThread(() -> { | ||||
|                         runnable.accept(context); | ||||
|                         inFlight(false); | ||||
|                     }); | ||||
|                 } else { | ||||
|                     getLifecycle().addObserver(new DefaultLifecycleObserver() { | ||||
|                         @Override | ||||
|                         public void onResume(@NonNull final LifecycleOwner owner) { | ||||
|                             getLifecycle().removeObserver(this); | ||||
|                             getActivityContext().ifPresentOrElse(context -> | ||||
|                                     context.runOnUiThread(() -> { | ||||
|                                         runnable.accept(context); | ||||
|                                         inFlight(false); | ||||
|                                     }), | ||||
|                                     () -> inFlight(false) | ||||
|                             ); | ||||
|                         } | ||||
|                     }); | ||||
|                     // this trick doesn't seem to work on Android 10+ (API 29) | ||||
|                     // which places restrictions on starting activities from the background | ||||
|                     if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q | ||||
|                             && !context.isChangingConfigurations()) { | ||||
|                         // try to bring the activity back to front if minimised | ||||
|                         final Intent i = new Intent(context, RouterActivity.class); | ||||
|                         i.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); | ||||
|                         startActivity(i); | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|             }, () -> { | ||||
|                 // this branch is executed if there is no activity context | ||||
|                 inFlight(false); | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         <T> Single<T> pleaseWait(final Single<T> single) { | ||||
|             // 'abuse' ambWith() here to cancel the toast for us when the wait is over | ||||
|             return single.ambWith(Single.create(emitter -> getActivityContext().ifPresent(context -> | ||||
|                     context.runOnUiThread(() -> { | ||||
|                         // Getting the stream info usually takes a moment | ||||
|                         // Notifying the user here to ensure that no confusion arises | ||||
|                         final Toast toast = Toast.makeText(context, | ||||
|                                 getString(R.string.processing_may_take_a_moment), | ||||
|                                 Toast.LENGTH_LONG); | ||||
|                         toast.show(); | ||||
|                         emitter.setCancellable(toast::cancel); | ||||
|             })))); | ||||
|         } | ||||
|  | ||||
|         @SuppressLint("CheckResult") | ||||
|         private void openDownloadDialog(final int currentServiceId, final String currentUrl) { | ||||
|             inFlight(true); | ||||
|             disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true) | ||||
|                     .subscribeOn(Schedulers.io()) | ||||
|                     .observeOn(AndroidSchedulers.mainThread()) | ||||
|                     .compose(this::pleaseWait) | ||||
|                     .subscribe(result -> | ||||
|                         runOnVisible(ctx -> { | ||||
|                             final FragmentManager fm = ctx.getSupportFragmentManager(); | ||||
|                             final DownloadDialog downloadDialog = new DownloadDialog(ctx, result); | ||||
|                             // dismiss listener to be handled by FragmentManager | ||||
|                             downloadDialog.show(fm, "downloadDialog"); | ||||
|                         } | ||||
|                     ), throwable -> runOnVisible(ctx -> | ||||
|                             ((RouterActivity) ctx).showUnsupportedUrlDialog(currentUrl)))); | ||||
|         } | ||||
|  | ||||
|         private void openAddToPlaylistDialog(final int currentServiceId, final String currentUrl) { | ||||
|             inFlight(true); | ||||
|             disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, false) | ||||
|                     .subscribeOn(Schedulers.io()) | ||||
|                     .observeOn(AndroidSchedulers.mainThread()) | ||||
|                     .compose(this::pleaseWait) | ||||
|                     .subscribe( | ||||
|                             info -> getActivityContext().ifPresent(context -> | ||||
|                                     PlaylistDialog.createCorrespondingDialog(context, | ||||
|                                             List.of(new StreamEntity(info)), | ||||
|                                             playlistDialog -> runOnVisible(ctx -> { | ||||
|                                                 // dismiss listener to be handled by FragmentManager | ||||
|                                                 final FragmentManager fm = | ||||
|                                                         ctx.getSupportFragmentManager(); | ||||
|                                                 playlistDialog.show(fm, "addToPlaylistDialog"); | ||||
|                                             }) | ||||
|                                     )), | ||||
|                             throwable -> runOnVisible(ctx -> handleError(ctx, new ErrorInfo( | ||||
|                                     throwable, | ||||
|                                     UserAction.REQUESTED_STREAM, | ||||
|                                     "Tried to add " + currentUrl + " to a playlist", | ||||
|                                     ((RouterActivity) ctx).currentService.getServiceId()) | ||||
|                             )) | ||||
|                     ) | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @SuppressLint("CheckResult") | ||||
|     private void openDownloadDialog() { | ||||
|         disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true) | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribe(result -> { | ||||
|                     final DownloadDialog downloadDialog = new DownloadDialog(this, result); | ||||
|                     downloadDialog.setOnDismissListener(dialog -> finish()); | ||||
|     private void openAddToPlaylistDialog() { | ||||
|         getPersistFragment().openAddToPlaylistDialog(currentServiceId, currentUrl); | ||||
|     } | ||||
|  | ||||
|                     final FragmentManager fm = getSupportFragmentManager(); | ||||
|                     downloadDialog.show(fm, "downloadDialog"); | ||||
|                     fm.executePendingTransactions(); | ||||
|                 }, throwable -> showUnsupportedUrlDialog(currentUrl))); | ||||
|     private void openDownloadDialog() { | ||||
|         getPersistFragment().openDownloadDialog(currentServiceId, currentUrl); | ||||
|     } | ||||
|  | ||||
|     private PersistentFragment getPersistFragment() { | ||||
|         final FragmentManager fm = getSupportFragmentManager(); | ||||
|         PersistentFragment persistFragment = | ||||
|                 (PersistentFragment) fm.findFragmentByTag("PERSIST_FRAGMENT"); | ||||
|         if (persistFragment == null) { | ||||
|             persistFragment = new PersistentFragment(); | ||||
|             fm.beginTransaction() | ||||
|                     .add(persistFragment, "PERSIST_FRAGMENT") | ||||
|                     .commitNow(); | ||||
|         } | ||||
|         return persistFragment; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| package org.schabi.newpipe.database; | ||||
|  | ||||
| import static org.schabi.newpipe.database.Migrations.DB_VER_5; | ||||
| import static org.schabi.newpipe.database.Migrations.DB_VER_6; | ||||
|  | ||||
| import androidx.room.Database; | ||||
| import androidx.room.RoomDatabase; | ||||
| @@ -38,7 +38,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity; | ||||
|                 FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class, | ||||
|                 FeedLastUpdatedEntity.class | ||||
|         }, | ||||
|         version = DB_VER_5 | ||||
|         version = DB_VER_6 | ||||
| ) | ||||
| public abstract class AppDatabase extends RoomDatabase { | ||||
|     public static final String DATABASE_NAME = "newpipe.db"; | ||||
|   | ||||
| @@ -23,6 +23,7 @@ public final class Migrations { | ||||
|     public static final int DB_VER_3 = 3; | ||||
|     public static final int DB_VER_4 = 4; | ||||
|     public static final int DB_VER_5 = 5; | ||||
|     public static final int DB_VER_6 = 6; | ||||
|  | ||||
|     private static final String TAG = Migrations.class.getName(); | ||||
|     public static final boolean DEBUG = MainActivity.DEBUG; | ||||
| @@ -188,6 +189,14 @@ public final class Migrations { | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     public static final Migration MIGRATION_5_6 = new Migration(DB_VER_5, DB_VER_6) { | ||||
|         @Override | ||||
|         public void migrate(@NonNull final SupportSQLiteDatabase database) { | ||||
|             database.execSQL("ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` " | ||||
|                     + "INTEGER NOT NULL DEFAULT 0"); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     private Migrations() { | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -25,6 +25,7 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JO | ||||
| import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE; | ||||
| import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID; | ||||
| import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE; | ||||
| import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_THUMBNAIL_URL; | ||||
| import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS; | ||||
| import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS; | ||||
| import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; | ||||
| @@ -53,6 +54,15 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> { | ||||
|             + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") | ||||
|     Flowable<Integer> getMaximumIndexOf(long playlistId); | ||||
|  | ||||
|     @Query("SELECT CASE WHEN COUNT(*) != 0 then " + STREAM_THUMBNAIL_URL + " ELSE :defaultUrl END" | ||||
|             + " FROM " + STREAM_TABLE | ||||
|             + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE | ||||
|             + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID | ||||
|             + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId " | ||||
|             + " LIMIT 1" | ||||
|     ) | ||||
|     Flowable<String> getAutomaticThumbnailUrl(long playlistId, String defaultUrl); | ||||
|  | ||||
|     @RewriteQueriesToDropUnusedColumns | ||||
|     @Transaction | ||||
|     @Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN " | ||||
| @@ -80,7 +90,7 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> { | ||||
|             + " FROM " + PLAYLIST_TABLE | ||||
|             + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE | ||||
|             + " ON " + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID | ||||
|             + " GROUP BY " + JOIN_PLAYLIST_ID | ||||
|             + " GROUP BY " + PLAYLIST_ID | ||||
|             + " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC") | ||||
|     Flowable<List<PlaylistMetadataEntry>> getPlaylistMetadata(); | ||||
| } | ||||
|   | ||||
| @@ -15,6 +15,7 @@ public class PlaylistEntity { | ||||
|     public static final String PLAYLIST_ID = "uid"; | ||||
|     public static final String PLAYLIST_NAME = "name"; | ||||
|     public static final String PLAYLIST_THUMBNAIL_URL = "thumbnail_url"; | ||||
|     public static final String PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent"; | ||||
|  | ||||
|     @PrimaryKey(autoGenerate = true) | ||||
|     @ColumnInfo(name = PLAYLIST_ID) | ||||
| @@ -26,9 +27,14 @@ public class PlaylistEntity { | ||||
|     @ColumnInfo(name = PLAYLIST_THUMBNAIL_URL) | ||||
|     private String thumbnailUrl; | ||||
|  | ||||
|     public PlaylistEntity(final String name, final String thumbnailUrl) { | ||||
|     @ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT) | ||||
|     private boolean isThumbnailPermanent; | ||||
|  | ||||
|     public PlaylistEntity(final String name, final String thumbnailUrl, | ||||
|                           final boolean isThumbnailPermanent) { | ||||
|         this.name = name; | ||||
|         this.thumbnailUrl = thumbnailUrl; | ||||
|         this.isThumbnailPermanent = isThumbnailPermanent; | ||||
|     } | ||||
|  | ||||
|     public long getUid() { | ||||
| @@ -54,4 +60,13 @@ public class PlaylistEntity { | ||||
|     public void setThumbnailUrl(final String thumbnailUrl) { | ||||
|         this.thumbnailUrl = thumbnailUrl; | ||||
|     } | ||||
|  | ||||
|     public boolean getIsThumbnailPermanent() { | ||||
|         return isThumbnailPermanent; | ||||
|     } | ||||
|  | ||||
|     public void setIsThumbnailPermanent(final boolean isThumbnailSet) { | ||||
|         this.isThumbnailPermanent = isThumbnailSet; | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -17,7 +17,6 @@ import android.os.Bundle; | ||||
| import android.os.Environment; | ||||
| import android.os.IBinder; | ||||
| import android.util.Log; | ||||
| import android.util.SparseArray; | ||||
| import android.view.LayoutInflater; | ||||
| import android.view.View; | ||||
| import android.view.ViewGroup; | ||||
| @@ -36,6 +35,7 @@ import androidx.annotation.StringRes; | ||||
| import androidx.appcompat.app.AlertDialog; | ||||
| import androidx.appcompat.view.menu.ActionMenuItemView; | ||||
| import androidx.appcompat.widget.Toolbar; | ||||
| import androidx.collection.SparseArrayCompat; | ||||
| import androidx.documentfile.provider.DocumentFile; | ||||
| import androidx.fragment.app.DialogFragment; | ||||
| import androidx.preference.PreferenceManager; | ||||
| @@ -75,6 +75,7 @@ import java.io.IOException; | ||||
| import java.util.List; | ||||
| import java.util.Locale; | ||||
| import java.util.Objects; | ||||
| import java.util.Optional; | ||||
|  | ||||
| import icepick.Icepick; | ||||
| import icepick.State; | ||||
| @@ -211,8 +212,7 @@ public class DownloadDialog extends DialogFragment | ||||
|         setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context)); | ||||
|         Icepick.restoreInstanceState(this, savedInstanceState); | ||||
|  | ||||
|         final SparseArray<SecondaryStreamHelper<AudioStream>> secondaryStreams = | ||||
|                 new SparseArray<>(4); | ||||
|         final var secondaryStreams = new SparseArrayCompat<SecondaryStreamHelper<AudioStream>>(4); | ||||
|         final List<VideoStream> videoStreams = wrappedVideoStreams.getStreamsList(); | ||||
|  | ||||
|         for (int i = 0; i < videoStreams.size(); i++) { | ||||
| @@ -236,10 +236,9 @@ public class DownloadDialog extends DialogFragment | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         this.videoStreamsAdapter = new StreamItemAdapter<>(context, wrappedVideoStreams, | ||||
|                 secondaryStreams); | ||||
|         this.audioStreamsAdapter = new StreamItemAdapter<>(context, wrappedAudioStreams); | ||||
|         this.subtitleStreamsAdapter = new StreamItemAdapter<>(context, wrappedSubtitleStreams); | ||||
|         this.videoStreamsAdapter = new StreamItemAdapter<>(wrappedVideoStreams, secondaryStreams); | ||||
|         this.audioStreamsAdapter = new StreamItemAdapter<>(wrappedAudioStreams); | ||||
|         this.subtitleStreamsAdapter = new StreamItemAdapter<>(wrappedSubtitleStreams); | ||||
|  | ||||
|         final Intent intent = new Intent(context, DownloadManagerService.class); | ||||
|         context.startService(intent); | ||||
| @@ -562,6 +561,39 @@ public class DownloadDialog extends DialogFragment | ||||
|                 selectedSubtitleIndex = position; | ||||
|                 break; | ||||
|         } | ||||
|         onItemSelectedSetFileName(); | ||||
|     } | ||||
|  | ||||
|     private void onItemSelectedSetFileName() { | ||||
|         final String fileName = FilenameUtils.createFilename(getContext(), currentInfo.getName()); | ||||
|         final String prevFileName = Optional.ofNullable(dialogBinding.fileName.getText()) | ||||
|                 .map(Object::toString) | ||||
|                 .orElse(""); | ||||
|  | ||||
|         if (prevFileName.isEmpty() | ||||
|                 || prevFileName.equals(fileName) | ||||
|                 || prevFileName.startsWith(getString(R.string.caption_file_name, fileName, ""))) { | ||||
|             // only update the file name field if it was not edited by the user | ||||
|  | ||||
|             switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) { | ||||
|                 case R.id.audio_button: | ||||
|                 case R.id.video_button: | ||||
|                     if (!prevFileName.equals(fileName)) { | ||||
|                         // since the user might have switched between audio and video, the correct | ||||
|                         // text might already be in place, so avoid resetting the cursor position | ||||
|                         dialogBinding.fileName.setText(fileName); | ||||
|                     } | ||||
|                     break; | ||||
|  | ||||
|                 case R.id.subtitle_button: | ||||
|                     final String setSubtitleLanguageCode = subtitleStreamsAdapter | ||||
|                             .getItem(selectedSubtitleIndex).getLanguageTag(); | ||||
|                     // this will reset the cursor position, which is bad UX, but it can't be avoided | ||||
|                     dialogBinding.fileName.setText(getString( | ||||
|                             R.string.caption_file_name, fileName, setSubtitleLanguageCode)); | ||||
|                     break; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|   | ||||
| @@ -5,7 +5,6 @@ import android.app.PendingIntent | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import android.graphics.Color | ||||
| import android.os.Build | ||||
| import android.view.View | ||||
| import android.widget.Toast | ||||
| import androidx.core.app.NotificationCompat | ||||
| @@ -13,6 +12,7 @@ import androidx.core.app.NotificationManagerCompat | ||||
| import androidx.fragment.app.Fragment | ||||
| import com.google.android.material.snackbar.Snackbar | ||||
| import org.schabi.newpipe.R | ||||
| import org.schabi.newpipe.util.PendingIntentCompat | ||||
|  | ||||
| /** | ||||
|  * This class contains all of the methods that should be used to let the user know that an error has | ||||
| @@ -104,11 +104,6 @@ class ErrorUtil { | ||||
|          */ | ||||
|         @JvmStatic | ||||
|         fun createNotification(context: Context, errorInfo: ErrorInfo) { | ||||
|             var pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT | ||||
|             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { | ||||
|                 pendingIntentFlags = pendingIntentFlags or PendingIntent.FLAG_IMMUTABLE | ||||
|             } | ||||
|  | ||||
|             val notificationBuilder: NotificationCompat.Builder = | ||||
|                 NotificationCompat.Builder( | ||||
|                     context, | ||||
| @@ -119,11 +114,11 @@ class ErrorUtil { | ||||
|                     .setContentText(context.getString(errorInfo.messageStringId)) | ||||
|                     .setAutoCancel(true) | ||||
|                     .setContentIntent( | ||||
|                         PendingIntent.getActivity( | ||||
|                         PendingIntentCompat.getActivity( | ||||
|                             context, | ||||
|                             0, | ||||
|                             getErrorActivityIntent(context, errorInfo), | ||||
|                             pendingIntentFlags | ||||
|                             PendingIntent.FLAG_UPDATE_CURRENT | ||||
|                         ) | ||||
|                     ) | ||||
|  | ||||
|   | ||||
| @@ -20,14 +20,14 @@ import androidx.appcompat.app.AppCompatActivity; | ||||
| import androidx.core.app.NavUtils; | ||||
| import androidx.preference.PreferenceManager; | ||||
|  | ||||
| import org.schabi.newpipe.databinding.ActivityRecaptchaBinding; | ||||
| import org.schabi.newpipe.DownloaderImpl; | ||||
| import org.schabi.newpipe.MainActivity; | ||||
| import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.databinding.ActivityRecaptchaBinding; | ||||
| import org.schabi.newpipe.extractor.utils.Utils; | ||||
| import org.schabi.newpipe.util.ThemeHelper; | ||||
|  | ||||
| import java.io.UnsupportedEncodingException; | ||||
| import java.net.URLDecoder; | ||||
|  | ||||
| /* | ||||
|  * Created by beneth <bmauduit@beneth.fr> on 06.12.16. | ||||
| @@ -188,7 +188,7 @@ public class ReCaptchaActivity extends AppCompatActivity { | ||||
|  | ||||
|             try { | ||||
|                 String abuseCookie = url.substring(abuseStart + 13, abuseEnd); | ||||
|                 abuseCookie = URLDecoder.decode(abuseCookie, "UTF-8"); | ||||
|                 abuseCookie = Utils.decodeUrlUtf8(abuseCookie); | ||||
|                 handleCookies(abuseCookie); | ||||
|             } catch (UnsupportedEncodingException | StringIndexOutOfBoundsException e) { | ||||
|                 if (MainActivity.DEBUG) { | ||||
|   | ||||
| @@ -3,6 +3,8 @@ package org.schabi.newpipe.fragments.detail; | ||||
| import static android.text.TextUtils.isEmpty; | ||||
| import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; | ||||
| import static org.schabi.newpipe.extractor.utils.Utils.isBlank; | ||||
| import static org.schabi.newpipe.util.Localization.getAppLocale; | ||||
| import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD; | ||||
|  | ||||
| import android.os.Bundle; | ||||
| import android.view.LayoutInflater; | ||||
| @@ -28,7 +30,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfo; | ||||
| import org.schabi.newpipe.util.Localization; | ||||
| import org.schabi.newpipe.util.NavigationHelper; | ||||
| import org.schabi.newpipe.util.external_communication.ShareUtils; | ||||
| import org.schabi.newpipe.util.external_communication.TextLinkifier; | ||||
| import org.schabi.newpipe.util.text.TextLinkifier; | ||||
|  | ||||
| import icepick.State; | ||||
| import io.reactivex.rxjava3.disposables.CompositeDisposable; | ||||
| @@ -111,7 +113,10 @@ public class DescriptionFragment extends BaseFragment { | ||||
|  | ||||
|     private void disableDescriptionSelection() { | ||||
|         // show description content again, otherwise some links are not clickable | ||||
|         loadDescriptionContent(); | ||||
|         TextLinkifier.fromDescription(binding.detailDescriptionView, | ||||
|                 streamInfo.getDescription(), HtmlCompat.FROM_HTML_MODE_LEGACY, | ||||
|                 streamInfo.getService(), streamInfo.getUrl(), | ||||
|                 descriptionDisposables, SET_LINK_MOVEMENT_METHOD); | ||||
|  | ||||
|         binding.detailDescriptionNoteView.setVisibility(View.GONE); | ||||
|         binding.detailDescriptionView.setTextIsSelectable(false); | ||||
| @@ -122,52 +127,32 @@ public class DescriptionFragment extends BaseFragment { | ||||
|         binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all); | ||||
|     } | ||||
|  | ||||
|     private void loadDescriptionContent() { | ||||
|         final Description description = streamInfo.getDescription(); | ||||
|         switch (description.getType()) { | ||||
|             case Description.HTML: | ||||
|                 TextLinkifier.createLinksFromHtmlBlock(binding.detailDescriptionView, | ||||
|                         description.getContent(), HtmlCompat.FROM_HTML_MODE_LEGACY, streamInfo, | ||||
|                         descriptionDisposables); | ||||
|                 break; | ||||
|             case Description.MARKDOWN: | ||||
|                 TextLinkifier.createLinksFromMarkdownText(binding.detailDescriptionView, | ||||
|                         description.getContent(), streamInfo, descriptionDisposables); | ||||
|                 break; | ||||
|             case Description.PLAIN_TEXT: default: | ||||
|                 TextLinkifier.createLinksFromPlainText(binding.detailDescriptionView, | ||||
|                         description.getContent(), streamInfo, descriptionDisposables); | ||||
|                 break; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     private void setupMetadata(final LayoutInflater inflater, | ||||
|                                final LinearLayout layout) { | ||||
|         addMetadataItem(inflater, layout, false, | ||||
|                 R.string.metadata_category, streamInfo.getCategory()); | ||||
|         addMetadataItem(inflater, layout, false, R.string.metadata_category, | ||||
|                 streamInfo.getCategory()); | ||||
|  | ||||
|         addMetadataItem(inflater, layout, false, | ||||
|                 R.string.metadata_licence, streamInfo.getLicence()); | ||||
|         addMetadataItem(inflater, layout, false, R.string.metadata_licence, | ||||
|                 streamInfo.getLicence()); | ||||
|  | ||||
|         addPrivacyMetadataItem(inflater, layout); | ||||
|  | ||||
|         if (streamInfo.getAgeLimit() != NO_AGE_LIMIT) { | ||||
|             addMetadataItem(inflater, layout, false, | ||||
|                     R.string.metadata_age_limit, String.valueOf(streamInfo.getAgeLimit())); | ||||
|             addMetadataItem(inflater, layout, false, R.string.metadata_age_limit, | ||||
|                     String.valueOf(streamInfo.getAgeLimit())); | ||||
|         } | ||||
|  | ||||
|         if (streamInfo.getLanguageInfo() != null) { | ||||
|             addMetadataItem(inflater, layout, false, | ||||
|                     R.string.metadata_language, streamInfo.getLanguageInfo().getDisplayLanguage()); | ||||
|             addMetadataItem(inflater, layout, false, R.string.metadata_language, | ||||
|                     streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale(getContext()))); | ||||
|         } | ||||
|  | ||||
|         addMetadataItem(inflater, layout, true, | ||||
|                 R.string.metadata_support, streamInfo.getSupportInfo()); | ||||
|         addMetadataItem(inflater, layout, true, | ||||
|                 R.string.metadata_host, streamInfo.getHost()); | ||||
|         addMetadataItem(inflater, layout, true, | ||||
|                 R.string.metadata_thumbnail_url, streamInfo.getThumbnailUrl()); | ||||
|         addMetadataItem(inflater, layout, true, R.string.metadata_support, | ||||
|                 streamInfo.getSupportInfo()); | ||||
|         addMetadataItem(inflater, layout, true, R.string.metadata_host, | ||||
|                 streamInfo.getHost()); | ||||
|         addMetadataItem(inflater, layout, true, R.string.metadata_thumbnail_url, | ||||
|                 streamInfo.getThumbnailUrl()); | ||||
|  | ||||
|         addTagsMetadataItem(inflater, layout); | ||||
|     } | ||||
| @@ -191,12 +176,14 @@ public class DescriptionFragment extends BaseFragment { | ||||
|         }); | ||||
|  | ||||
|         if (linkifyContent) { | ||||
|             TextLinkifier.createLinksFromPlainText(itemBinding.metadataContentView, content, null, | ||||
|                     descriptionDisposables); | ||||
|             TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null, | ||||
|                     descriptionDisposables, SET_LINK_MOVEMENT_METHOD); | ||||
|         } else { | ||||
|             itemBinding.metadataContentView.setText(content); | ||||
|         } | ||||
|  | ||||
|         itemBinding.metadataContentView.setClickable(true); | ||||
|  | ||||
|         layout.addView(itemBinding.getRoot()); | ||||
|     } | ||||
|  | ||||
| @@ -245,14 +232,15 @@ public class DescriptionFragment extends BaseFragment { | ||||
|                 case INTERNAL: | ||||
|                     contentRes = R.string.metadata_privacy_internal; | ||||
|                     break; | ||||
|                 case OTHER: default: | ||||
|                 case OTHER: | ||||
|                 default: | ||||
|                     contentRes = 0; | ||||
|                     break; | ||||
|             } | ||||
|  | ||||
|             if (contentRes != 0) { | ||||
|                 addMetadataItem(inflater, layout, false, | ||||
|                         R.string.metadata_privacy, getString(contentRes)); | ||||
|                 addMetadataItem(inflater, layout, false, R.string.metadata_privacy, | ||||
|                         getString(contentRes)); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -10,8 +10,11 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfi | ||||
| import static org.schabi.newpipe.player.playqueue.PlayQueueItem.RECOVERY_UNSET; | ||||
| import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; | ||||
| import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; | ||||
| import static org.schabi.newpipe.util.NavigationHelper.openPlayQueue; | ||||
| import static org.schabi.newpipe.util.NavigationHelper.playWithKore; | ||||
|  | ||||
| import android.animation.ValueAnimator; | ||||
| import android.annotation.SuppressLint; | ||||
| import android.app.Activity; | ||||
| import android.content.BroadcastReceiver; | ||||
| import android.content.Context; | ||||
| @@ -24,7 +27,6 @@ import android.graphics.Color; | ||||
| import android.graphics.Rect; | ||||
| import android.graphics.drawable.Drawable; | ||||
| import android.net.Uri; | ||||
| import android.os.Build; | ||||
| import android.os.Bundle; | ||||
| import android.os.Handler; | ||||
| import android.os.Looper; | ||||
| @@ -52,6 +54,9 @@ import androidx.appcompat.content.res.AppCompatResources; | ||||
| import androidx.appcompat.widget.Toolbar; | ||||
| import androidx.coordinatorlayout.widget.CoordinatorLayout; | ||||
| import androidx.core.content.ContextCompat; | ||||
| import androidx.core.view.WindowCompat; | ||||
| import androidx.core.view.WindowInsetsCompat; | ||||
| import androidx.core.view.WindowInsetsControllerCompat; | ||||
| import androidx.preference.PreferenceManager; | ||||
|  | ||||
| import com.google.android.exoplayer2.PlaybackException; | ||||
| @@ -119,6 +124,7 @@ import java.util.List; | ||||
| import java.util.Objects; | ||||
| import java.util.Optional; | ||||
| import java.util.concurrent.TimeUnit; | ||||
| import java.util.function.Consumer; | ||||
|  | ||||
| import icepick.State; | ||||
| import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; | ||||
| @@ -129,9 +135,6 @@ import io.reactivex.rxjava3.schedulers.Schedulers; | ||||
| public final class VideoDetailFragment | ||||
|         extends BaseStateFragment<StreamInfo> | ||||
|         implements BackPressable, | ||||
|         SharedPreferences.OnSharedPreferenceChangeListener, | ||||
|         View.OnClickListener, | ||||
|         View.OnLongClickListener, | ||||
|         PlayerServiceExtendedEventListener, | ||||
|         OnKeyDownListener { | ||||
|     public static final String KEY_SWITCHING_PLAYERS = "switching_players"; | ||||
| @@ -167,6 +170,20 @@ public final class VideoDetailFragment | ||||
|     private boolean tabSettingsChanged = false; | ||||
|     private int lastAppBarVerticalOffset = Integer.MAX_VALUE; // prevents useless updates | ||||
|  | ||||
|     private final SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener = | ||||
|             (sharedPreferences, key) -> { | ||||
|                 if (getString(R.string.show_comments_key).equals(key)) { | ||||
|                     showComments = sharedPreferences.getBoolean(key, true); | ||||
|                     tabSettingsChanged = true; | ||||
|                 } else if (getString(R.string.show_next_video_key).equals(key)) { | ||||
|                     showRelatedItems = sharedPreferences.getBoolean(key, true); | ||||
|                     tabSettingsChanged = true; | ||||
|                 } else if (getString(R.string.show_description_key).equals(key)) { | ||||
|                     showDescription = sharedPreferences.getBoolean(key, true); | ||||
|                     tabSettingsChanged = true; | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|     @State | ||||
|     protected int serviceId = Constants.NO_SERVICE_ID; | ||||
|     @State | ||||
| @@ -240,11 +257,10 @@ public final class VideoDetailFragment | ||||
|             playerUi.ifPresent(MainPlayerUi::toggleFullscreen); | ||||
|         } | ||||
|  | ||||
|         //noinspection SimplifyOptionalCallChains | ||||
|         if (playAfterConnect | ||||
|                 || (currentInfo != null | ||||
|                 && isAutoplayEnabled() | ||||
|                 && !playerUi.isPresent())) { | ||||
|                 && playerUi.isEmpty())) { | ||||
|             autoPlayEnabled = true; // forcefully start playing | ||||
|             openVideoPlayerAutoFullscreen(); | ||||
|         } | ||||
| @@ -291,7 +307,7 @@ public final class VideoDetailFragment | ||||
|         showDescription = prefs.getBoolean(getString(R.string.show_description_key), true); | ||||
|         selectedTabTag = prefs.getString( | ||||
|                 getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG); | ||||
|         prefs.registerOnSharedPreferenceChangeListener(this); | ||||
|         prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener); | ||||
|  | ||||
|         setupBroadcastReceiver(); | ||||
|  | ||||
| @@ -378,7 +394,7 @@ public final class VideoDetailFragment | ||||
|         } | ||||
|  | ||||
|         PreferenceManager.getDefaultSharedPreferences(activity) | ||||
|                 .unregisterOnSharedPreferenceChangeListener(this); | ||||
|                 .unregisterOnSharedPreferenceChangeListener(preferenceChangeListener); | ||||
|         activity.unregisterReceiver(broadcastReceiver); | ||||
|         activity.getContentResolver().unregisterContentObserver(settingsContentObserver); | ||||
|  | ||||
| @@ -424,130 +440,129 @@ public final class VideoDetailFragment | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, | ||||
|                                           final String key) { | ||||
|         if (key.equals(getString(R.string.show_comments_key))) { | ||||
|             showComments = sharedPreferences.getBoolean(key, true); | ||||
|             tabSettingsChanged = true; | ||||
|         } else if (key.equals(getString(R.string.show_next_video_key))) { | ||||
|             showRelatedItems = sharedPreferences.getBoolean(key, true); | ||||
|             tabSettingsChanged = true; | ||||
|         } else if (key.equals(getString(R.string.show_description_key))) { | ||||
|             showDescription = sharedPreferences.getBoolean(key, true); | ||||
|             tabSettingsChanged = true; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /*////////////////////////////////////////////////////////////////////////// | ||||
|     // OnClick | ||||
|     //////////////////////////////////////////////////////////////////////////*/ | ||||
|  | ||||
|     @Override | ||||
|     public void onClick(final View v) { | ||||
|         switch (v.getId()) { | ||||
|             case R.id.detail_controls_background: | ||||
|                 openBackgroundPlayer(false); | ||||
|                 break; | ||||
|             case R.id.detail_controls_popup: | ||||
|                 openPopupPlayer(false); | ||||
|                 break; | ||||
|             case R.id.detail_controls_playlist_append: | ||||
|                 if (getFM() != null && currentInfo != null) { | ||||
|                     disposables.add( | ||||
|                             PlaylistDialog.createCorrespondingDialog( | ||||
|                                     getContext(), | ||||
|                                     List.of(new StreamEntity(currentInfo)), | ||||
|                                     dialog -> dialog.show(getFM(), TAG) | ||||
|                             ) | ||||
|                     ); | ||||
|                 } | ||||
|                 break; | ||||
|             case R.id.detail_controls_download: | ||||
|                 if (PermissionHelper.checkStoragePermissions(activity, | ||||
|                         PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { | ||||
|                     this.openDownloadDialog(); | ||||
|                 } | ||||
|                 break; | ||||
|             case R.id.detail_controls_share: | ||||
|                 if (currentInfo != null) { | ||||
|                     ShareUtils.shareText(requireContext(), currentInfo.getName(), | ||||
|                             currentInfo.getUrl(), currentInfo.getThumbnailUrl()); | ||||
|                 } | ||||
|                 break; | ||||
|             case R.id.detail_controls_open_in_browser: | ||||
|                 if (currentInfo != null) { | ||||
|                     ShareUtils.openUrlInBrowser(requireContext(), currentInfo.getUrl()); | ||||
|                 } | ||||
|                 break; | ||||
|             case R.id.detail_controls_play_with_kodi: | ||||
|                 if (currentInfo != null) { | ||||
|                     try { | ||||
|                         NavigationHelper.playWithKore( | ||||
|                                 requireContext(), Uri.parse(currentInfo.getUrl())); | ||||
|                     } catch (final Exception e) { | ||||
|                         if (DEBUG) { | ||||
|                             Log.i(TAG, "Failed to start kore", e); | ||||
|                         } | ||||
|                         KoreUtils.showInstallKoreDialog(requireContext()); | ||||
|                     } | ||||
|                 } | ||||
|                 break; | ||||
|             case R.id.detail_uploader_root_layout: | ||||
|                 if (isEmpty(currentInfo.getSubChannelUrl())) { | ||||
|                     if (!isEmpty(currentInfo.getUploaderUrl())) { | ||||
|                         openChannel(currentInfo.getUploaderUrl(), currentInfo.getUploaderName()); | ||||
|                     } | ||||
|  | ||||
|                     if (DEBUG) { | ||||
|                         Log.i(TAG, "Can't open sub-channel because we got no channel URL"); | ||||
|                     } | ||||
|                 } else { | ||||
|                     openChannel(currentInfo.getSubChannelUrl(), | ||||
|                             currentInfo.getSubChannelName()); | ||||
|                 } | ||||
|                 break; | ||||
|             case R.id.detail_thumbnail_root_layout: | ||||
|                 // make sure not to open any player if there is nothing currently loaded! | ||||
|                 // FIXME removing this `if` causes the player service to start correctly, then stop, | ||||
|                 //  then restart badly without calling `startForeground()`, causing a crash when | ||||
|                 //  later closing the detail fragment | ||||
|                 if (currentInfo != null) { | ||||
|                     autoPlayEnabled = true; // forcefully start playing | ||||
|                     // FIXME Workaround #7427 | ||||
|                     if (isPlayerAvailable()) { | ||||
|                         player.setRecovery(); | ||||
|                     } | ||||
|                     openVideoPlayerAutoFullscreen(); | ||||
|                 } | ||||
|                 break; | ||||
|             case R.id.detail_title_root_layout: | ||||
|                 toggleTitleAndSecondaryControls(); | ||||
|                 break; | ||||
|             case R.id.overlay_thumbnail: | ||||
|             case R.id.overlay_metadata_layout: | ||||
|             case R.id.overlay_buttons_layout: | ||||
|                 bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); | ||||
|                 break; | ||||
|             case R.id.overlay_play_queue_button: | ||||
|                 NavigationHelper.openPlayQueue(getContext()); | ||||
|                 break; | ||||
|             case R.id.overlay_play_pause_button: | ||||
|                 if (playerIsNotStopped()) { | ||||
|                     player.playPause(); | ||||
|                     player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); | ||||
|                     showSystemUi(); | ||||
|                 } else { | ||||
|                     autoPlayEnabled = true; // forcefully start playing | ||||
|                     openVideoPlayer(false); | ||||
|     private void setOnClickListeners() { | ||||
|         binding.detailTitleRootLayout.setOnClickListener(v -> toggleTitleAndSecondaryControls()); | ||||
|         binding.detailUploaderRootLayout.setOnClickListener(makeOnClickListener(info -> { | ||||
|             if (isEmpty(info.getSubChannelUrl())) { | ||||
|                 if (!isEmpty(info.getUploaderUrl())) { | ||||
|                     openChannel(info.getUploaderUrl(), info.getUploaderName()); | ||||
|                 } | ||||
|  | ||||
|                 setOverlayPlayPauseImage(isPlayerAvailable() && player.isPlaying()); | ||||
|                 break; | ||||
|             case R.id.overlay_close_button: | ||||
|                 bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); | ||||
|                 break; | ||||
|                 if (DEBUG) { | ||||
|                     Log.i(TAG, "Can't open sub-channel because we got no channel URL"); | ||||
|                 } | ||||
|             } else { | ||||
|                 openChannel(info.getSubChannelUrl(), info.getSubChannelName()); | ||||
|             } | ||||
|         })); | ||||
|         binding.detailThumbnailRootLayout.setOnClickListener(v -> { | ||||
|             autoPlayEnabled = true; // forcefully start playing | ||||
|             // FIXME Workaround #7427 | ||||
|             if (isPlayerAvailable()) { | ||||
|                 player.setRecovery(); | ||||
|             } | ||||
|             openVideoPlayerAutoFullscreen(); | ||||
|         }); | ||||
|  | ||||
|         binding.detailControlsBackground.setOnClickListener(v -> openBackgroundPlayer(false)); | ||||
|         binding.detailControlsPopup.setOnClickListener(v -> openPopupPlayer(false)); | ||||
|         binding.detailControlsPlaylistAppend.setOnClickListener(makeOnClickListener(info -> | ||||
|                 disposables.add(PlaylistDialog.createCorrespondingDialog(requireContext(), | ||||
|                         List.of(new StreamEntity(info)), | ||||
|                         dialog -> dialog.show(getParentFragmentManager(), TAG))))); | ||||
|         binding.detailControlsDownload.setOnClickListener(v -> { | ||||
|             if (PermissionHelper.checkStoragePermissions(activity, | ||||
|                     PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { | ||||
|                 openDownloadDialog(); | ||||
|             } | ||||
|         }); | ||||
|         binding.detailControlsShare.setOnClickListener(makeOnClickListener(info -> | ||||
|                 ShareUtils.shareText(requireContext(), info.getName(), info.getUrl(), | ||||
|                         info.getThumbnailUrl()))); | ||||
|         binding.detailControlsOpenInBrowser.setOnClickListener(makeOnClickListener(info -> | ||||
|                 ShareUtils.openUrlInBrowser(requireContext(), info.getUrl()))); | ||||
|         binding.detailControlsPlayWithKodi.setOnClickListener(makeOnClickListener(info -> { | ||||
|             try { | ||||
|                 playWithKore(requireContext(), Uri.parse(info.getUrl())); | ||||
|             } catch (final Exception e) { | ||||
|                 if (DEBUG) { | ||||
|                     Log.i(TAG, "Failed to start kore", e); | ||||
|                 } | ||||
|                 KoreUtils.showInstallKoreDialog(requireContext()); | ||||
|             } | ||||
|         })); | ||||
|         if (DEBUG) { | ||||
|             binding.detailControlsCrashThePlayer.setOnClickListener(v -> | ||||
|                     VideoDetailPlayerCrasher.onCrashThePlayer(requireContext(), player)); | ||||
|         } | ||||
|  | ||||
|         final View.OnClickListener overlayListener = v -> bottomSheetBehavior | ||||
|                 .setState(BottomSheetBehavior.STATE_EXPANDED); | ||||
|         binding.overlayThumbnail.setOnClickListener(overlayListener); | ||||
|         binding.overlayMetadataLayout.setOnClickListener(overlayListener); | ||||
|         binding.overlayButtonsLayout.setOnClickListener(overlayListener); | ||||
|         binding.overlayCloseButton.setOnClickListener(v -> bottomSheetBehavior | ||||
|                 .setState(BottomSheetBehavior.STATE_HIDDEN)); | ||||
|         binding.overlayPlayQueueButton.setOnClickListener(v -> openPlayQueue(requireContext())); | ||||
|         binding.overlayPlayPauseButton.setOnClickListener(v -> { | ||||
|             if (playerIsNotStopped()) { | ||||
|                 player.playPause(); | ||||
|                 player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); | ||||
|                 showSystemUi(); | ||||
|             } else { | ||||
|                 autoPlayEnabled = true; // forcefully start playing | ||||
|                 openVideoPlayer(false); | ||||
|             } | ||||
|  | ||||
|             setOverlayPlayPauseImage(isPlayerAvailable() && player.isPlaying()); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     private View.OnClickListener makeOnClickListener(final Consumer<StreamInfo> consumer) { | ||||
|         return v -> { | ||||
|             if (!isLoading.get() && currentInfo != null) { | ||||
|                 consumer.accept(currentInfo); | ||||
|             } | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private void setOnLongClickListeners() { | ||||
|         binding.detailTitleRootLayout.setOnLongClickListener(makeOnLongClickListener(info -> | ||||
|                 ShareUtils.copyToClipboard(requireContext(), | ||||
|                         binding.detailVideoTitleView.getText().toString()))); | ||||
|         binding.detailUploaderRootLayout.setOnLongClickListener(makeOnLongClickListener(info -> { | ||||
|             if (isEmpty(info.getSubChannelUrl())) { | ||||
|                 Log.w(TAG, "Can't open parent channel because we got no parent channel URL"); | ||||
|             } else { | ||||
|                 openChannel(info.getUploaderUrl(), info.getUploaderName()); | ||||
|             } | ||||
|         })); | ||||
|  | ||||
|         binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info -> | ||||
|                 openBackgroundPlayer(true))); | ||||
|         binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info -> | ||||
|                 openPopupPlayer(true))); | ||||
|         binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info -> | ||||
|                 NavigationHelper.openDownloads(activity))); | ||||
|  | ||||
|         final View.OnLongClickListener overlayListener = makeOnLongClickListener(info -> | ||||
|                 openChannel(info.getUploaderUrl(), info.getUploaderName())); | ||||
|         binding.overlayThumbnail.setOnLongClickListener(overlayListener); | ||||
|         binding.overlayMetadataLayout.setOnLongClickListener(overlayListener); | ||||
|     } | ||||
|  | ||||
|     private View.OnLongClickListener makeOnLongClickListener(final Consumer<StreamInfo> consumer) { | ||||
|         return v -> { | ||||
|             if (isLoading.get() || currentInfo == null) { | ||||
|                 return false; | ||||
|             } | ||||
|             consumer.accept(currentInfo); | ||||
|             return true; | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private void openChannel(final String subChannelUrl, final String subChannelName) { | ||||
| @@ -559,43 +574,6 @@ public final class VideoDetailFragment | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public boolean onLongClick(final View v) { | ||||
|         if (isLoading.get() || currentInfo == null) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         switch (v.getId()) { | ||||
|             case R.id.detail_controls_background: | ||||
|                 openBackgroundPlayer(true); | ||||
|                 break; | ||||
|             case R.id.detail_controls_popup: | ||||
|                 openPopupPlayer(true); | ||||
|                 break; | ||||
|             case R.id.detail_controls_download: | ||||
|                 NavigationHelper.openDownloads(activity); | ||||
|                 break; | ||||
|             case R.id.overlay_thumbnail: | ||||
|             case R.id.overlay_metadata_layout: | ||||
|                 openChannel(currentInfo.getUploaderUrl(), currentInfo.getUploaderName()); | ||||
|                 break; | ||||
|             case R.id.detail_uploader_root_layout: | ||||
|                 if (isEmpty(currentInfo.getSubChannelUrl())) { | ||||
|                     Log.w(TAG, | ||||
|                             "Can't open parent channel because we got no parent channel URL"); | ||||
|                 } else { | ||||
|                     openChannel(currentInfo.getUploaderUrl(), currentInfo.getUploaderName()); | ||||
|                 } | ||||
|                 break; | ||||
|             case R.id.detail_title_root_layout: | ||||
|                 ShareUtils.copyToClipboard(requireContext(), | ||||
|                         binding.detailVideoTitleView.getText().toString()); | ||||
|                 break; | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     private void toggleTitleAndSecondaryControls() { | ||||
|         if (binding.detailSecondaryControlPanel.getVisibility() == View.GONE) { | ||||
|             binding.detailVideoTitleView.setMaxLines(10); | ||||
| @@ -616,11 +594,6 @@ public final class VideoDetailFragment | ||||
|     // Init | ||||
|     //////////////////////////////////////////////////////////////////////////*/ | ||||
|  | ||||
|     @Override | ||||
|     public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { | ||||
|         super.onViewCreated(rootView, savedInstanceState); | ||||
|     } | ||||
|  | ||||
|     @Override // called from onViewCreated in {@link BaseFragment#onViewCreated} | ||||
|     protected void initViews(final View rootView, final Bundle savedInstanceState) { | ||||
|         super.initViews(rootView, savedInstanceState); | ||||
| @@ -642,60 +615,29 @@ public final class VideoDetailFragment | ||||
|                         ? View.VISIBLE | ||||
|                         : View.GONE | ||||
|         ); | ||||
|  | ||||
|         if (DeviceUtils.isTv(getContext())) { | ||||
|             // remove ripple effects from detail controls | ||||
|             final int transparent = ContextCompat.getColor(requireContext(), | ||||
|                     R.color.transparent_background_color); | ||||
|             binding.detailControlsPlaylistAppend.setBackgroundColor(transparent); | ||||
|             binding.detailControlsBackground.setBackgroundColor(transparent); | ||||
|             binding.detailControlsPopup.setBackgroundColor(transparent); | ||||
|             binding.detailControlsDownload.setBackgroundColor(transparent); | ||||
|             binding.detailControlsShare.setBackgroundColor(transparent); | ||||
|             binding.detailControlsOpenInBrowser.setBackgroundColor(transparent); | ||||
|             binding.detailControlsPlayWithKodi.setBackgroundColor(transparent); | ||||
|         } | ||||
|         accommodateForTvAndDesktopMode(); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     @SuppressLint("ClickableViewAccessibility") | ||||
|     protected void initListeners() { | ||||
|         super.initListeners(); | ||||
|  | ||||
|         binding.detailTitleRootLayout.setOnClickListener(this); | ||||
|         binding.detailTitleRootLayout.setOnLongClickListener(this); | ||||
|         binding.detailUploaderRootLayout.setOnClickListener(this); | ||||
|         binding.detailUploaderRootLayout.setOnLongClickListener(this); | ||||
|         binding.detailThumbnailRootLayout.setOnClickListener(this); | ||||
|         setOnClickListeners(); | ||||
|         setOnLongClickListeners(); | ||||
|  | ||||
|         binding.detailControlsBackground.setOnClickListener(this); | ||||
|         binding.detailControlsBackground.setOnLongClickListener(this); | ||||
|         binding.detailControlsPopup.setOnClickListener(this); | ||||
|         binding.detailControlsPopup.setOnLongClickListener(this); | ||||
|         binding.detailControlsPlaylistAppend.setOnClickListener(this); | ||||
|         binding.detailControlsDownload.setOnClickListener(this); | ||||
|         binding.detailControlsDownload.setOnLongClickListener(this); | ||||
|         binding.detailControlsShare.setOnClickListener(this); | ||||
|         binding.detailControlsOpenInBrowser.setOnClickListener(this); | ||||
|         binding.detailControlsPlayWithKodi.setOnClickListener(this); | ||||
|         if (DEBUG) { | ||||
|             binding.detailControlsCrashThePlayer.setOnClickListener( | ||||
|                     v -> VideoDetailPlayerCrasher.onCrashThePlayer( | ||||
|                             this.getContext(), | ||||
|                             this.player) | ||||
|             ); | ||||
|         } | ||||
|         final View.OnTouchListener controlsTouchListener = (view, motionEvent) -> { | ||||
|             if (motionEvent.getAction() == MotionEvent.ACTION_DOWN | ||||
|                 && PreferenceManager.getDefaultSharedPreferences(activity) | ||||
|                     .getBoolean(getString(R.string.show_hold_to_append_key), true)) { | ||||
|  | ||||
|         binding.overlayThumbnail.setOnClickListener(this); | ||||
|         binding.overlayThumbnail.setOnLongClickListener(this); | ||||
|         binding.overlayMetadataLayout.setOnClickListener(this); | ||||
|         binding.overlayMetadataLayout.setOnLongClickListener(this); | ||||
|         binding.overlayButtonsLayout.setOnClickListener(this); | ||||
|         binding.overlayPlayQueueButton.setOnClickListener(this); | ||||
|         binding.overlayCloseButton.setOnClickListener(this); | ||||
|         binding.overlayPlayPauseButton.setOnClickListener(this); | ||||
|  | ||||
|         binding.detailControlsBackground.setOnTouchListener(getOnControlsTouchListener()); | ||||
|         binding.detailControlsPopup.setOnTouchListener(getOnControlsTouchListener()); | ||||
|                 animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, 0, () -> | ||||
|                         animate(binding.touchAppendDetail, false, 1500, AnimationType.ALPHA, 1000)); | ||||
|             } | ||||
|             return false; | ||||
|         }; | ||||
|         binding.detailControlsBackground.setOnTouchListener(controlsTouchListener); | ||||
|         binding.detailControlsPopup.setOnTouchListener(controlsTouchListener); | ||||
|  | ||||
|         binding.appBarLayout.addOnOffsetChangedListener((layout, verticalOffset) -> { | ||||
|             // prevent useless updates to tab layout visibility if nothing changed | ||||
| @@ -714,23 +656,6 @@ public final class VideoDetailFragment | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private View.OnTouchListener getOnControlsTouchListener() { | ||||
|         return (view, motionEvent) -> { | ||||
|             if (!PreferenceManager.getDefaultSharedPreferences(activity) | ||||
|                     .getBoolean(getString(R.string.show_hold_to_append_key), true)) { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             if (motionEvent.getAction() == MotionEvent.ACTION_DOWN) { | ||||
|                 animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, | ||||
|                         0, () -> | ||||
|                         animate(binding.touchAppendDetail, false, 1500, | ||||
|                                 AnimationType.ALPHA, 1000)); | ||||
|             } | ||||
|             return false; | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private void initThumbnailViews(@NonNull final StreamInfo info) { | ||||
|         PicassoHelper.loadDetailsThumbnail(info.getThumbnailUrl()).tag(PICASSO_VIDEO_DETAILS_TAG) | ||||
|                 .into(binding.detailThumbnailImageView, new Callback() { | ||||
| @@ -940,7 +865,8 @@ public final class VideoDetailFragment | ||||
|                             if (playQueue == null) { | ||||
|                                 playQueue = new SinglePlayQueue(result); | ||||
|                             } | ||||
|                             if (stack.isEmpty() || !stack.peek().getPlayQueue().equals(playQueue)) { | ||||
|                             if (stack.isEmpty() || !stack.peek().getPlayQueue() | ||||
|                                     .equalStreams(playQueue)) { | ||||
|                                 stack.push(new StackItem(serviceId, url, title, playQueue)); | ||||
|                             } | ||||
|                         } | ||||
| @@ -1143,8 +1069,7 @@ public final class VideoDetailFragment | ||||
|     } | ||||
|  | ||||
|     private void openPopupPlayer(final boolean append) { | ||||
|         if (!PermissionHelper.isPopupEnabled(activity)) { | ||||
|             PermissionHelper.showPopupEnablementToast(activity); | ||||
|         if (!PermissionHelper.isPopupEnabledElseAsk(activity)) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
| @@ -1250,16 +1175,15 @@ public final class VideoDetailFragment | ||||
|      * be reused in a few milliseconds and the flickering would be annoying. | ||||
|      */ | ||||
|     private void hideMainPlayerOnLoadingNewStream() { | ||||
|         //noinspection SimplifyOptionalCallChains | ||||
|         if (!isPlayerServiceAvailable() || !getRoot().isPresent() | ||||
|                 || !player.videoPlayerSelected()) { | ||||
|         final var root = getRoot(); | ||||
|         if (!isPlayerServiceAvailable() || root.isEmpty() || !player.videoPlayerSelected()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         removeVideoPlayerView(); | ||||
|         if (isAutoplayEnabled()) { | ||||
|             playerService.stopForImmediateReusing(); | ||||
|             getRoot().ifPresent(view -> view.setVisibility(View.GONE)); | ||||
|             root.ifPresent(view -> view.setVisibility(View.GONE)); | ||||
|         } else { | ||||
|             playerHolder.stopService(); | ||||
|         } | ||||
| @@ -1573,9 +1497,9 @@ public final class VideoDetailFragment | ||||
|         binding.detailSubChannelThumbnailView.setVisibility(View.GONE); | ||||
|  | ||||
|         if (!isEmpty(info.getSubChannelName())) { | ||||
|             displayBothUploaderAndSubChannel(info); | ||||
|             displayBothUploaderAndSubChannel(info, activity); | ||||
|         } else if (!isEmpty(info.getUploaderName())) { | ||||
|             displayUploaderAsSubChannel(info); | ||||
|             displayUploaderAsSubChannel(info, activity); | ||||
|         } else { | ||||
|             binding.detailUploaderTextView.setVisibility(View.GONE); | ||||
|             binding.detailUploaderThumbnailView.setVisibility(View.GONE); | ||||
| @@ -1678,8 +1602,9 @@ public final class VideoDetailFragment | ||||
|  | ||||
|         binding.detailControlsDownload.setVisibility( | ||||
|                 StreamTypeUtil.isLiveStream(info.getStreamType()) ? View.GONE : View.VISIBLE); | ||||
|         binding.detailControlsBackground.setVisibility(info.getAudioStreams().isEmpty() | ||||
|                 ? View.GONE : View.VISIBLE); | ||||
|         binding.detailControlsBackground.setVisibility( | ||||
|                 info.getAudioStreams().isEmpty() && info.getVideoStreams().isEmpty() | ||||
|                         ? View.GONE : View.VISIBLE); | ||||
|  | ||||
|         final boolean noVideoStreams = | ||||
|                 info.getVideoStreams().isEmpty() && info.getVideoOnlyStreams().isEmpty(); | ||||
| @@ -1688,23 +1613,42 @@ public final class VideoDetailFragment | ||||
|                 noVideoStreams ? R.drawable.ic_headset_shadow : R.drawable.ic_play_arrow_shadow); | ||||
|     } | ||||
|  | ||||
|     private void displayUploaderAsSubChannel(final StreamInfo info) { | ||||
|     private void displayUploaderAsSubChannel(final StreamInfo info, final Context context) { | ||||
|         binding.detailSubChannelTextView.setText(info.getUploaderName()); | ||||
|         binding.detailSubChannelTextView.setVisibility(View.VISIBLE); | ||||
|         binding.detailSubChannelTextView.setSelected(true); | ||||
|         binding.detailUploaderTextView.setVisibility(View.GONE); | ||||
|  | ||||
|         if (info.getUploaderSubscriberCount() > -1) { | ||||
|             binding.detailUploaderTextView.setText( | ||||
|                     Localization.shortSubscriberCount(context, info.getUploaderSubscriberCount())); | ||||
|             binding.detailUploaderTextView.setVisibility(View.VISIBLE); | ||||
|         } else { | ||||
|             binding.detailUploaderTextView.setVisibility(View.GONE); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void displayBothUploaderAndSubChannel(final StreamInfo info) { | ||||
|     private void displayBothUploaderAndSubChannel(final StreamInfo info, final Context context) { | ||||
|         binding.detailSubChannelTextView.setText(info.getSubChannelName()); | ||||
|         binding.detailSubChannelTextView.setVisibility(View.VISIBLE); | ||||
|         binding.detailSubChannelTextView.setSelected(true); | ||||
|  | ||||
|         binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); | ||||
|  | ||||
|         final StringBuilder subText = new StringBuilder(); | ||||
|         if (!isEmpty(info.getUploaderName())) { | ||||
|             binding.detailUploaderTextView.setText( | ||||
|             subText.append( | ||||
|                     String.format(getString(R.string.video_detail_by), info.getUploaderName())); | ||||
|         } | ||||
|         if (info.getUploaderSubscriberCount() > -1) { | ||||
|             if (subText.length() > 0) { | ||||
|                 subText.append(Localization.DOT_SEPARATOR); | ||||
|             } | ||||
|             subText.append( | ||||
|                     Localization.shortSubscriberCount(context, info.getUploaderSubscriberCount())); | ||||
|         } | ||||
|  | ||||
|         if (subText.length() > 0) { | ||||
|             binding.detailUploaderTextView.setText(subText); | ||||
|             binding.detailUploaderTextView.setVisibility(View.VISIBLE); | ||||
|             binding.detailUploaderTextView.setSelected(true); | ||||
|         } else { | ||||
| @@ -1836,7 +1780,7 @@ public final class VideoDetailFragment | ||||
|         // deleted/added items inside Channel/Playlist queue and makes possible to have | ||||
|         // a history of played items | ||||
|         @Nullable final StackItem stackPeek = stack.peek(); | ||||
|         if (stackPeek != null && !stackPeek.getPlayQueue().equals(queue)) { | ||||
|         if (stackPeek != null && !stackPeek.getPlayQueue().equalStreams(queue)) { | ||||
|             @Nullable final PlayQueueItem playQueueItem = queue.getItem(); | ||||
|             if (playQueueItem != null) { | ||||
|                 stack.push(new StackItem(playQueueItem.getServiceId(), playQueueItem.getUrl(), | ||||
| @@ -1902,7 +1846,7 @@ public final class VideoDetailFragment | ||||
|         // They are not equal when user watches something in popup while browsing in fragment and | ||||
|         // then changes screen orientation. In that case the fragment will set itself as | ||||
|         // a service listener and will receive initial call to onMetadataUpdate() | ||||
|         if (!queue.equals(playQueue)) { | ||||
|         if (!queue.equalStreams(playQueue)) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
| @@ -1943,10 +1887,9 @@ public final class VideoDetailFragment | ||||
|     @Override | ||||
|     public void onFullscreenStateChanged(final boolean fullscreen) { | ||||
|         setupBrightness(); | ||||
|         //noinspection SimplifyOptionalCallChains | ||||
|         if (!isPlayerAndPlayerServiceAvailable() | ||||
|                 || !player.UIs().get(MainPlayerUi.class).isPresent() | ||||
|                 || getRoot().map(View::getParent).orElse(null) == null) { | ||||
|                 || player.UIs().get(MainPlayerUi.class).isEmpty() | ||||
|                 || getRoot().map(View::getParent).isEmpty()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
| @@ -2018,15 +1961,17 @@ public final class VideoDetailFragment | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Prevent jumping of the player on devices with cutout | ||||
|         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { | ||||
|             activity.getWindow().getAttributes().layoutInDisplayCutoutMode = | ||||
|                     WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT; | ||||
|         } | ||||
|         activity.getWindow().getDecorView().setSystemUiVisibility(0); | ||||
|         activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); | ||||
|         activity.getWindow().setStatusBarColor(ThemeHelper.resolveColorFromAttr( | ||||
|                 requireContext(), android.R.attr.colorPrimary)); | ||||
|         final var window = activity.getWindow(); | ||||
|         final var windowInsetsController = WindowCompat.getInsetsController(window, | ||||
|                 window.getDecorView()); | ||||
|  | ||||
|         WindowCompat.setDecorFitsSystemWindows(window, true); | ||||
|         windowInsetsController.setSystemBarsBehavior(WindowInsetsControllerCompat | ||||
|                 .BEHAVIOR_SHOW_BARS_BY_TOUCH); | ||||
|         windowInsetsController.show(WindowInsetsCompat.Type.systemBars()); | ||||
|  | ||||
|         window.setStatusBarColor(ThemeHelper.resolveColorFromAttr(requireContext(), | ||||
|                 android.R.attr.colorPrimary)); | ||||
|     } | ||||
|  | ||||
|     private void hideSystemUi() { | ||||
| @@ -2038,30 +1983,19 @@ public final class VideoDetailFragment | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Prevent jumping of the player on devices with cutout | ||||
|         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { | ||||
|             activity.getWindow().getAttributes().layoutInDisplayCutoutMode = | ||||
|                     WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; | ||||
|         } | ||||
|         int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | ||||
|                 | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | ||||
|                 | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | ||||
|                 | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | ||||
|                 | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; | ||||
|         final var window = activity.getWindow(); | ||||
|         final var windowInsetsController = WindowCompat.getInsetsController(window, | ||||
|                 window.getDecorView()); | ||||
|  | ||||
|         // In multiWindow mode status bar is not transparent for devices with cutout | ||||
|         // if I include this flag. So without it is better in this case | ||||
|         final boolean isInMultiWindow = DeviceUtils.isInMultiWindow(activity); | ||||
|         if (!isInMultiWindow) { | ||||
|             visibility |= View.SYSTEM_UI_FLAG_FULLSCREEN; | ||||
|         } | ||||
|         activity.getWindow().getDecorView().setSystemUiVisibility(visibility); | ||||
|         WindowCompat.setDecorFitsSystemWindows(window, false); | ||||
|         windowInsetsController.setSystemBarsBehavior(WindowInsetsControllerCompat | ||||
|                 .BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); | ||||
|         windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()); | ||||
|  | ||||
|         if (isInMultiWindow || isFullscreen()) { | ||||
|             activity.getWindow().setStatusBarColor(Color.TRANSPARENT); | ||||
|             activity.getWindow().setNavigationBarColor(Color.TRANSPARENT); | ||||
|         if (DeviceUtils.isInMultiWindow(activity) || isFullscreen()) { | ||||
|             window.setStatusBarColor(Color.TRANSPARENT); | ||||
|             window.setNavigationBarColor(Color.TRANSPARENT); | ||||
|         } | ||||
|         activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); | ||||
|     } | ||||
|  | ||||
|     // Listener implementation | ||||
| @@ -2118,6 +2052,30 @@ public final class VideoDetailFragment | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Make changes to the UI to accommodate for better usability on bigger screens such as TVs | ||||
|      * or in Android's desktop mode (DeX etc). | ||||
|      */ | ||||
|     private void accommodateForTvAndDesktopMode() { | ||||
|         if (DeviceUtils.isTv(getContext())) { | ||||
|             // remove ripple effects from detail controls | ||||
|             final int transparent = ContextCompat.getColor(requireContext(), | ||||
|                     R.color.transparent_background_color); | ||||
|             binding.detailControlsPlaylistAppend.setBackgroundColor(transparent); | ||||
|             binding.detailControlsBackground.setBackgroundColor(transparent); | ||||
|             binding.detailControlsPopup.setBackgroundColor(transparent); | ||||
|             binding.detailControlsDownload.setBackgroundColor(transparent); | ||||
|             binding.detailControlsShare.setBackgroundColor(transparent); | ||||
|             binding.detailControlsOpenInBrowser.setBackgroundColor(transparent); | ||||
|             binding.detailControlsPlayWithKodi.setBackgroundColor(transparent); | ||||
|         } | ||||
|         if (DeviceUtils.isDesktopMode(getContext())) { | ||||
|             // Remove the "hover" overlay (since it is visible on all mouse events and interferes | ||||
|             // with the video content being played) | ||||
|             binding.detailThumbnailRootLayout.setForeground(null); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void checkLandscape() { | ||||
|         if ((!player.isPlaying() && player.getPlayQueue() != playQueue) | ||||
|                 || player.getPlayQueue() == null) { | ||||
| @@ -2145,7 +2103,7 @@ public final class VideoDetailFragment | ||||
|         final Iterator<StackItem> iterator = stack.descendingIterator(); | ||||
|         while (iterator.hasNext()) { | ||||
|             final StackItem next = iterator.next(); | ||||
|             if (next.getPlayQueue().equals(queue)) { | ||||
|             if (next.getPlayQueue().equalStreams(queue)) { | ||||
|                 item = next; | ||||
|                 break; | ||||
|             } | ||||
| @@ -2160,7 +2118,7 @@ public final class VideoDetailFragment | ||||
|         if (isClearingQueueConfirmationRequired(activity) | ||||
|                 && playerIsNotStopped() | ||||
|                 && activeQueue != null | ||||
|                 && !activeQueue.equals(playQueue)) { | ||||
|                 && !activeQueue.equalStreams(playQueue)) { | ||||
|             showClearingQueueConfirmation(onAllow); | ||||
|         } else { | ||||
|             onAllow.run(); | ||||
| @@ -2461,23 +2419,20 @@ public final class VideoDetailFragment | ||||
|  | ||||
|     // helpers to check the state of player and playerService | ||||
|     boolean isPlayerAvailable() { | ||||
|         return (player != null); | ||||
|         return player != null; | ||||
|     } | ||||
|  | ||||
|     boolean isPlayerServiceAvailable() { | ||||
|         return (playerService != null); | ||||
|         return playerService != null; | ||||
|     } | ||||
|  | ||||
|     boolean isPlayerAndPlayerServiceAvailable() { | ||||
|         return (player != null && playerService != null); | ||||
|         return player != null && playerService != null; | ||||
|     } | ||||
|  | ||||
|     public Optional<View> getRoot() { | ||||
|         if (player == null) { | ||||
|             return Optional.empty(); | ||||
|         } | ||||
|  | ||||
|         return player.UIs().get(VideoPlayerUi.class) | ||||
|         return Optional.ofNullable(player) | ||||
|                 .flatMap(player1 -> player1.UIs().get(VideoPlayerUi.class)) | ||||
|                 .map(playerUi -> playerUi.getBinding().getRoot()); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -26,6 +26,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem; | ||||
| import org.schabi.newpipe.fragments.BaseStateFragment; | ||||
| import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; | ||||
| import org.schabi.newpipe.info_list.InfoListAdapter; | ||||
| import org.schabi.newpipe.info_list.ItemViewMode; | ||||
| import org.schabi.newpipe.info_list.dialog.InfoItemDialog; | ||||
| import org.schabi.newpipe.util.NavigationHelper; | ||||
| import org.schabi.newpipe.util.OnClickGesture; | ||||
| @@ -91,11 +92,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> | ||||
|  | ||||
|         if (updateFlags != 0) { | ||||
|             if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) { | ||||
|                 final boolean useGrid = isGridLayout(); | ||||
|                 itemsList.setLayoutManager(useGrid | ||||
|                         ? getGridLayoutManager() : getListLayoutManager()); | ||||
|                 infoListAdapter.setUseGridVariant(useGrid); | ||||
|                 infoListAdapter.notifyDataSetChanged(); | ||||
|                 refreshItemViewMode(); | ||||
|             } | ||||
|             updateFlags = 0; | ||||
|         } | ||||
| @@ -215,22 +212,29 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> | ||||
|         final Resources resources = activity.getResources(); | ||||
|         int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width); | ||||
|         width += (24 * resources.getDisplayMetrics().density); | ||||
|         final int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels | ||||
|                 / (double) width); | ||||
|         final int spanCount = Math.floorDiv(resources.getDisplayMetrics().widthPixels, width); | ||||
|         final GridLayoutManager lm = new GridLayoutManager(activity, spanCount); | ||||
|         lm.setSpanSizeLookup(infoListAdapter.getSpanSizeLookup(spanCount)); | ||||
|         return lm; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Updates the item view mode based on user preference. | ||||
|      */ | ||||
|     private void refreshItemViewMode() { | ||||
|         final ItemViewMode itemViewMode = getItemViewMode(); | ||||
|         itemsList.setLayoutManager((itemViewMode == ItemViewMode.GRID) | ||||
|                 ? getGridLayoutManager() : getListLayoutManager()); | ||||
|         infoListAdapter.setItemViewMode(itemViewMode); | ||||
|         infoListAdapter.notifyDataSetChanged(); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     protected void initViews(final View rootView, final Bundle savedInstanceState) { | ||||
|         super.initViews(rootView, savedInstanceState); | ||||
|  | ||||
|         final boolean useGrid = isGridLayout(); | ||||
|         itemsList = rootView.findViewById(R.id.items_list); | ||||
|         itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager()); | ||||
|  | ||||
|         infoListAdapter.setUseGridVariant(useGrid); | ||||
|         refreshItemViewMode(); | ||||
|  | ||||
|         final Supplier<View> listHeaderSupplier = getListHeaderSupplier(); | ||||
|         if (listHeaderSupplier != null) { | ||||
| @@ -470,12 +474,16 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> | ||||
|     @Override | ||||
|     public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, | ||||
|                                           final String key) { | ||||
|         if (key.equals(getString(R.string.list_view_mode_key))) { | ||||
|         if (getString(R.string.list_view_mode_key).equals(key)) { | ||||
|             updateFlags |= LIST_MODE_UPDATE_FLAG; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     protected boolean isGridLayout() { | ||||
|         return ThemeHelper.shouldUseGridLayout(activity); | ||||
|     /** | ||||
|      * Returns preferred item view mode. | ||||
|      * @return ItemViewMode | ||||
|      */ | ||||
|     protected ItemViewMode getItemViewMode() { | ||||
|         return ThemeHelper.getItemViewMode(requireContext()); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -17,6 +17,7 @@ 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.info_list.ItemViewMode; | ||||
| import org.schabi.newpipe.ktx.ViewUtils; | ||||
| import org.schabi.newpipe.util.ExtractorHelper; | ||||
|  | ||||
| @@ -106,7 +107,7 @@ public class CommentsFragment extends BaseListInfoFragment<CommentsInfoItem, Com | ||||
|                                     @NonNull final MenuInflater inflater) { } | ||||
|  | ||||
|     @Override | ||||
|     protected boolean isGridLayout() { | ||||
|         return false; | ||||
|     protected ItemViewMode getItemViewMode() { | ||||
|         return ItemViewMode.LIST; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -132,6 +132,8 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl | ||||
|     protected void initViews(final View rootView, final Bundle savedInstanceState) { | ||||
|         super.initViews(rootView, savedInstanceState); | ||||
|  | ||||
|         // Is mini variant still relevant? | ||||
|         // Only the remote playlist screen uses it now | ||||
|         infoListAdapter.setUseMiniVariant(true); | ||||
|     } | ||||
|  | ||||
| @@ -230,24 +232,24 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl | ||||
|                 ShareUtils.openUrlInBrowser(requireContext(), url); | ||||
|                 break; | ||||
|             case R.id.menu_item_share: | ||||
|                 if (currentInfo != null) { | ||||
|                     ShareUtils.shareText(requireContext(), name, url, | ||||
|                             currentInfo.getThumbnailUrl()); | ||||
|                 } | ||||
|                 ShareUtils.shareText(requireContext(), name, url, | ||||
|                         currentInfo == null ? null : currentInfo.getThumbnailUrl()); | ||||
|                 break; | ||||
|             case R.id.menu_item_bookmark: | ||||
|                 onBookmarkClicked(); | ||||
|                 break; | ||||
|             case R.id.menu_item_append_playlist: | ||||
|                 disposables.add(PlaylistDialog.createCorrespondingDialog( | ||||
|                         getContext(), | ||||
|                         getPlayQueue() | ||||
|                                 .getStreams() | ||||
|                                 .stream() | ||||
|                                 .map(StreamEntity::new) | ||||
|                                 .collect(Collectors.toList()), | ||||
|                         dialog -> dialog.show(getFM(), TAG) | ||||
|                 )); | ||||
|                 if (currentInfo != null) { | ||||
|                     disposables.add(PlaylistDialog.createCorrespondingDialog( | ||||
|                             getContext(), | ||||
|                             getPlayQueue() | ||||
|                                     .getStreams() | ||||
|                                     .stream() | ||||
|                                     .map(StreamEntity::new) | ||||
|                                     .collect(Collectors.toList()), | ||||
|                             dialog -> dialog.show(getFM(), TAG) | ||||
|                     )); | ||||
|                 } | ||||
|                 break; | ||||
|             default: | ||||
|                 return super.onOptionsItemSelected(item); | ||||
|   | ||||
| @@ -33,6 +33,7 @@ import androidx.annotation.Nullable; | ||||
| import androidx.appcompat.app.ActionBar; | ||||
| import androidx.appcompat.app.AlertDialog; | ||||
| import androidx.appcompat.widget.TooltipCompat; | ||||
| import androidx.collection.SparseArrayCompat; | ||||
| import androidx.core.text.HtmlCompat; | ||||
| import androidx.preference.PreferenceManager; | ||||
| import androidx.recyclerview.widget.ItemTouchHelper; | ||||
| @@ -70,9 +71,7 @@ import org.schabi.newpipe.util.ServiceHelper; | ||||
| import java.util.ArrayList; | ||||
| import java.util.Arrays; | ||||
| import java.util.Collections; | ||||
| import java.util.HashMap; | ||||
| import java.util.List; | ||||
| import java.util.Map; | ||||
| import java.util.Queue; | ||||
| import java.util.concurrent.TimeUnit; | ||||
| import java.util.stream.Collectors; | ||||
| @@ -141,7 +140,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I | ||||
|     @State | ||||
|     boolean wasSearchFocused = false; | ||||
|  | ||||
|     @Nullable private Map<Integer, String> menuItemToFilterName = null; | ||||
|     private final SparseArrayCompat<String> menuItemToFilterName = new SparseArrayCompat<>(); | ||||
|     private StreamingService service; | ||||
|     private Page nextPage; | ||||
|     private boolean showLocalSuggestions = true; | ||||
| @@ -426,8 +425,6 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I | ||||
|             supportActionBar.setDisplayHomeAsUpEnabled(true); | ||||
|         } | ||||
|  | ||||
|         menuItemToFilterName = new HashMap<>(); | ||||
|  | ||||
|         int itemId = 0; | ||||
|         boolean isFirstItem = true; | ||||
|         final Context c = getContext(); | ||||
| @@ -468,11 +465,8 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I | ||||
|  | ||||
|     @Override | ||||
|     public boolean onOptionsItemSelected(@NonNull final MenuItem item) { | ||||
|         if (menuItemToFilterName != null) { | ||||
|             final List<String> cf = new ArrayList<>(1); | ||||
|             cf.add(menuItemToFilterName.get(item.getItemId())); | ||||
|             changeContentFilter(item, cf); | ||||
|         } | ||||
|         final var filter = Collections.singletonList(menuItemToFilterName.get(item.getItemId())); | ||||
|         changeContentFilter(item, filter); | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -19,6 +19,7 @@ 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; | ||||
| import org.schabi.newpipe.info_list.ItemViewMode; | ||||
| import org.schabi.newpipe.ktx.ViewUtils; | ||||
| import org.schabi.newpipe.util.RelatedItemInfo; | ||||
|  | ||||
| @@ -158,16 +159,19 @@ public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, Related | ||||
|  | ||||
|     @Override | ||||
|     public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, | ||||
|                                           final String s) { | ||||
|         if (headerBinding != null) { | ||||
|             headerBinding.autoplaySwitch.setChecked( | ||||
|                     sharedPreferences.getBoolean( | ||||
|                             getString(R.string.auto_queue_key), false)); | ||||
|                                           final String key) { | ||||
|         if (headerBinding != null && getString(R.string.auto_queue_key).equals(key)) { | ||||
|             headerBinding.autoplaySwitch.setChecked(sharedPreferences.getBoolean(key, false)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     protected boolean isGridLayout() { | ||||
|         return false; | ||||
|     protected ItemViewMode getItemViewMode() { | ||||
|         ItemViewMode mode = super.getItemViewMode(); | ||||
|         // Only list mode is supported. Either List or card will be used. | ||||
|         if (mode != ItemViewMode.LIST && mode != ItemViewMode.CARD) { | ||||
|             mode = ItemViewMode.LIST; | ||||
|         } | ||||
|         return mode; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -23,9 +23,11 @@ import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder; | ||||
| import org.schabi.newpipe.info_list.holder.CommentsInfoItemHolder; | ||||
| import org.schabi.newpipe.info_list.holder.CommentsMiniInfoItemHolder; | ||||
| import org.schabi.newpipe.info_list.holder.InfoItemHolder; | ||||
| import org.schabi.newpipe.info_list.holder.PlaylistCardInfoItemHolder; | ||||
| import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder; | ||||
| import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder; | ||||
| import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder; | ||||
| import org.schabi.newpipe.info_list.holder.StreamCardInfoItemHolder; | ||||
| import org.schabi.newpipe.info_list.holder.StreamGridInfoItemHolder; | ||||
| import org.schabi.newpipe.info_list.holder.StreamInfoItemHolder; | ||||
| import org.schabi.newpipe.info_list.holder.StreamMiniInfoItemHolder; | ||||
| @@ -67,12 +69,14 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde | ||||
|     private static final int MINI_STREAM_HOLDER_TYPE = 0x100; | ||||
|     private static final int STREAM_HOLDER_TYPE = 0x101; | ||||
|     private static final int GRID_STREAM_HOLDER_TYPE = 0x102; | ||||
|     private static final int CARD_STREAM_HOLDER_TYPE = 0x103; | ||||
|     private static final int MINI_CHANNEL_HOLDER_TYPE = 0x200; | ||||
|     private static final int CHANNEL_HOLDER_TYPE = 0x201; | ||||
|     private static final int GRID_CHANNEL_HOLDER_TYPE = 0x202; | ||||
|     private static final int MINI_PLAYLIST_HOLDER_TYPE = 0x300; | ||||
|     private static final int PLAYLIST_HOLDER_TYPE = 0x301; | ||||
|     private static final int GRID_PLAYLIST_HOLDER_TYPE = 0x302; | ||||
|     private static final int CARD_PLAYLIST_HOLDER_TYPE = 0x303; | ||||
|     private static final int MINI_COMMENT_HOLDER_TYPE = 0x400; | ||||
|     private static final int COMMENT_HOLDER_TYPE = 0x401; | ||||
|  | ||||
| @@ -82,9 +86,10 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde | ||||
|     private final HistoryRecordManager recordManager; | ||||
|  | ||||
|     private boolean useMiniVariant = false; | ||||
|     private boolean useGridVariant = false; | ||||
|     private boolean showFooter = false; | ||||
|  | ||||
|     private ItemViewMode itemMode = ItemViewMode.LIST; | ||||
|  | ||||
|     private Supplier<View> headerSupplier = null; | ||||
|  | ||||
|     public InfoListAdapter(final Context context) { | ||||
| @@ -114,8 +119,8 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde | ||||
|         this.useMiniVariant = useMiniVariant; | ||||
|     } | ||||
|  | ||||
|     public void setUseGridVariant(final boolean useGridVariant) { | ||||
|         this.useGridVariant = useGridVariant; | ||||
|     public void setItemViewMode(final ItemViewMode itemViewMode) { | ||||
|         this.itemMode = itemViewMode; | ||||
|     } | ||||
|  | ||||
|     public void addInfoItemList(@Nullable final List<? extends InfoItem> data) { | ||||
| @@ -234,14 +239,33 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde | ||||
|         final InfoItem item = infoItemList.get(position); | ||||
|         switch (item.getInfoType()) { | ||||
|             case STREAM: | ||||
|                 return useGridVariant ? GRID_STREAM_HOLDER_TYPE : useMiniVariant | ||||
|                         ? MINI_STREAM_HOLDER_TYPE : STREAM_HOLDER_TYPE; | ||||
|                 if (itemMode == ItemViewMode.CARD) { | ||||
|                     return CARD_STREAM_HOLDER_TYPE; | ||||
|                 } else if (itemMode == ItemViewMode.GRID) { | ||||
|                     return GRID_STREAM_HOLDER_TYPE; | ||||
|                 } else if (useMiniVariant) { | ||||
|                     return MINI_STREAM_HOLDER_TYPE; | ||||
|                 } else { | ||||
|                     return STREAM_HOLDER_TYPE; | ||||
|                 } | ||||
|             case CHANNEL: | ||||
|                 return useGridVariant ? GRID_CHANNEL_HOLDER_TYPE : useMiniVariant | ||||
|                         ? MINI_CHANNEL_HOLDER_TYPE : CHANNEL_HOLDER_TYPE; | ||||
|                 if (itemMode == ItemViewMode.GRID) { | ||||
|                     return GRID_CHANNEL_HOLDER_TYPE; | ||||
|                 } else if (useMiniVariant) { | ||||
|                     return MINI_CHANNEL_HOLDER_TYPE; | ||||
|                 } else { | ||||
|                     return CHANNEL_HOLDER_TYPE; | ||||
|                 } | ||||
|             case PLAYLIST: | ||||
|                 return useGridVariant ? GRID_PLAYLIST_HOLDER_TYPE : useMiniVariant | ||||
|                         ? MINI_PLAYLIST_HOLDER_TYPE : PLAYLIST_HOLDER_TYPE; | ||||
|                 if (itemMode == ItemViewMode.CARD) { | ||||
|                     return CARD_PLAYLIST_HOLDER_TYPE; | ||||
|                 } else if (itemMode == ItemViewMode.GRID) { | ||||
|                     return GRID_PLAYLIST_HOLDER_TYPE; | ||||
|                 } else if (useMiniVariant) { | ||||
|                     return MINI_PLAYLIST_HOLDER_TYPE; | ||||
|                 } else { | ||||
|                     return PLAYLIST_HOLDER_TYPE; | ||||
|                 } | ||||
|             case COMMENT: | ||||
|                 return useMiniVariant ? MINI_COMMENT_HOLDER_TYPE : COMMENT_HOLDER_TYPE; | ||||
|             default: | ||||
| @@ -274,6 +298,8 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde | ||||
|                 return new StreamInfoItemHolder(infoItemBuilder, parent); | ||||
|             case GRID_STREAM_HOLDER_TYPE: | ||||
|                 return new StreamGridInfoItemHolder(infoItemBuilder, parent); | ||||
|             case CARD_STREAM_HOLDER_TYPE: | ||||
|                 return new StreamCardInfoItemHolder(infoItemBuilder, parent); | ||||
|             case MINI_CHANNEL_HOLDER_TYPE: | ||||
|                 return new ChannelMiniInfoItemHolder(infoItemBuilder, parent); | ||||
|             case CHANNEL_HOLDER_TYPE: | ||||
| @@ -286,6 +312,8 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde | ||||
|                 return new PlaylistInfoItemHolder(infoItemBuilder, parent); | ||||
|             case GRID_PLAYLIST_HOLDER_TYPE: | ||||
|                 return new PlaylistGridInfoItemHolder(infoItemBuilder, parent); | ||||
|             case CARD_PLAYLIST_HOLDER_TYPE: | ||||
|                 return new PlaylistCardInfoItemHolder(infoItemBuilder, parent); | ||||
|             case MINI_COMMENT_HOLDER_TYPE: | ||||
|                 return new CommentsMiniInfoItemHolder(infoItemBuilder, parent); | ||||
|             case COMMENT_HOLDER_TYPE: | ||||
|   | ||||
| @@ -0,0 +1,23 @@ | ||||
| package org.schabi.newpipe.info_list; | ||||
|  | ||||
| /** | ||||
|  * Item view mode for streams & playlist listing screens. | ||||
|  */ | ||||
| public enum ItemViewMode { | ||||
|     /** | ||||
|      * Default mode. | ||||
|      */ | ||||
|     AUTO, | ||||
|     /** | ||||
|      * Full width list item with thumb on the left and two line title & uploader in right. | ||||
|      */ | ||||
|     LIST, | ||||
|     /** | ||||
|      * Grid mode places two cards per row. | ||||
|      */ | ||||
|     GRID, | ||||
|     /** | ||||
|      * A full width card in phone - portrait. | ||||
|      */ | ||||
|     CARD | ||||
| } | ||||
| @@ -252,10 +252,11 @@ public final class InfoItemDialog { | ||||
|          * @return the current {@link Builder} instance | ||||
|          */ | ||||
|         public Builder addEnqueueEntriesIfNeeded() { | ||||
|             if (PlayerHolder.getInstance().isPlayQueueReady()) { | ||||
|             final PlayerHolder holder = PlayerHolder.getInstance(); | ||||
|             if (holder.isPlayQueueReady()) { | ||||
|                 addEntry(StreamDialogDefaultEntry.ENQUEUE); | ||||
|  | ||||
|                 if (PlayerHolder.getInstance().getQueueSize() > 1) { | ||||
|                 if (holder.getQueuePosition() < holder.getQueueSize() - 1) { | ||||
|                     addEntry(StreamDialogDefaultEntry.ENQUEUE_NEXT); | ||||
|                 } | ||||
|             } | ||||
|   | ||||
| @@ -1,14 +1,9 @@ | ||||
| package org.schabi.newpipe.info_list.holder; | ||||
|  | ||||
| import android.view.ViewGroup; | ||||
| import android.widget.TextView; | ||||
|  | ||||
| import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.extractor.InfoItem; | ||||
| import org.schabi.newpipe.extractor.channel.ChannelInfoItem; | ||||
| import org.schabi.newpipe.info_list.InfoItemBuilder; | ||||
| import org.schabi.newpipe.local.history.HistoryRecordManager; | ||||
| import org.schabi.newpipe.util.Localization; | ||||
|  | ||||
| /* | ||||
|  * Created by Christian Schabesberger on 12.02.17. | ||||
| @@ -31,40 +26,7 @@ import org.schabi.newpipe.util.Localization; | ||||
|  */ | ||||
|  | ||||
| public class ChannelInfoItemHolder extends ChannelMiniInfoItemHolder { | ||||
|     private final TextView itemChannelDescriptionView; | ||||
|  | ||||
|     public ChannelInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { | ||||
|         super(infoItemBuilder, R.layout.list_channel_item, parent); | ||||
|         itemChannelDescriptionView = itemView.findViewById(R.id.itemChannelDescriptionView); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void updateFromItem(final InfoItem infoItem, | ||||
|                                final HistoryRecordManager historyRecordManager) { | ||||
|         super.updateFromItem(infoItem, historyRecordManager); | ||||
|  | ||||
|         if (!(infoItem instanceof ChannelInfoItem)) { | ||||
|             return; | ||||
|         } | ||||
|         final ChannelInfoItem item = (ChannelInfoItem) infoItem; | ||||
|  | ||||
|         itemChannelDescriptionView.setText(item.getDescription()); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     protected String getDetailLine(final ChannelInfoItem item) { | ||||
|         String details = super.getDetailLine(item); | ||||
|  | ||||
|         if (item.getStreamCount() >= 0) { | ||||
|             final String formattedVideoAmount = Localization.localizeStreamCount( | ||||
|                     itemBuilder.getContext(), item.getStreamCount()); | ||||
|  | ||||
|             if (!details.isEmpty()) { | ||||
|                 details += " • " + formattedVideoAmount; | ||||
|             } else { | ||||
|                 details = formattedVideoAmount; | ||||
|             } | ||||
|         } | ||||
|         return details; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,21 +1,26 @@ | ||||
| package org.schabi.newpipe.info_list.holder; | ||||
|  | ||||
| import android.view.View; | ||||
| import android.view.ViewGroup; | ||||
| import android.widget.ImageView; | ||||
| import android.widget.TextView; | ||||
|  | ||||
| import androidx.annotation.Nullable; | ||||
|  | ||||
| import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.extractor.InfoItem; | ||||
| import org.schabi.newpipe.extractor.channel.ChannelInfoItem; | ||||
| import org.schabi.newpipe.extractor.utils.Utils; | ||||
| import org.schabi.newpipe.info_list.InfoItemBuilder; | ||||
| import org.schabi.newpipe.local.history.HistoryRecordManager; | ||||
| import org.schabi.newpipe.util.PicassoHelper; | ||||
| import org.schabi.newpipe.util.Localization; | ||||
|  | ||||
| public class ChannelMiniInfoItemHolder extends InfoItemHolder { | ||||
|     public final ImageView itemThumbnailView; | ||||
|     public final TextView itemTitleView; | ||||
|     private final ImageView itemThumbnailView; | ||||
|     private final TextView itemTitleView; | ||||
|     private final TextView itemAdditionalDetailView; | ||||
|     private final TextView itemChannelDescriptionView; | ||||
|  | ||||
|     ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, | ||||
|                               final ViewGroup parent) { | ||||
| @@ -24,6 +29,7 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder { | ||||
|         itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); | ||||
|         itemTitleView = itemView.findViewById(R.id.itemTitleView); | ||||
|         itemAdditionalDetailView = itemView.findViewById(R.id.itemAdditionalDetails); | ||||
|         itemChannelDescriptionView = itemView.findViewById(R.id.itemChannelDescriptionView); | ||||
|     } | ||||
|  | ||||
|     public ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, | ||||
| @@ -40,7 +46,14 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder { | ||||
|         final ChannelInfoItem item = (ChannelInfoItem) infoItem; | ||||
|  | ||||
|         itemTitleView.setText(item.getName()); | ||||
|         itemAdditionalDetailView.setText(getDetailLine(item)); | ||||
|  | ||||
|         final String detailLine = getDetailLine(item); | ||||
|         if (detailLine == null) { | ||||
|             itemAdditionalDetailView.setVisibility(View.GONE); | ||||
|         } else { | ||||
|             itemAdditionalDetailView.setVisibility(View.VISIBLE); | ||||
|             itemAdditionalDetailView.setText(getDetailLine(item)); | ||||
|         } | ||||
|  | ||||
|         PicassoHelper.loadAvatar(item.getThumbnailUrl()).into(itemThumbnailView); | ||||
|  | ||||
| @@ -56,14 +69,35 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder { | ||||
|             } | ||||
|             return true; | ||||
|         }); | ||||
|  | ||||
|         if (itemChannelDescriptionView != null) { | ||||
|             // itemChannelDescriptionView will be null in the mini variant | ||||
|             if (Utils.isBlank(item.getDescription())) { | ||||
|                 itemChannelDescriptionView.setVisibility(View.GONE); | ||||
|             } else { | ||||
|                 itemChannelDescriptionView.setVisibility(View.VISIBLE); | ||||
|                 itemChannelDescriptionView.setText(item.getDescription()); | ||||
|                 itemChannelDescriptionView.setMaxLines(detailLine == null ? 3 : 2); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     protected String getDetailLine(final ChannelInfoItem item) { | ||||
|         String details = ""; | ||||
|         if (item.getSubscriberCount() >= 0) { | ||||
|             details += Localization.shortSubscriberCount(itemBuilder.getContext(), | ||||
|     @Nullable | ||||
|     private String getDetailLine(final ChannelInfoItem item) { | ||||
|         if (item.getStreamCount() >= 0 && item.getSubscriberCount() >= 0) { | ||||
|             return Localization.concatenateStrings( | ||||
|                     Localization.shortSubscriberCount(itemBuilder.getContext(), | ||||
|                             item.getSubscriberCount()), | ||||
|                     Localization.localizeStreamCount(itemBuilder.getContext(), | ||||
|                             item.getStreamCount())); | ||||
|         } else if (item.getStreamCount() >= 0) { | ||||
|             return Localization.localizeStreamCount(itemBuilder.getContext(), | ||||
|                     item.getStreamCount()); | ||||
|         } else if (item.getSubscriberCount() >= 0) { | ||||
|             return Localization.shortSubscriberCount(itemBuilder.getContext(), | ||||
|                     item.getSubscriberCount()); | ||||
|         } else { | ||||
|             return null; | ||||
|         } | ||||
|         return details; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,9 +1,10 @@ | ||||
| package org.schabi.newpipe.info_list.holder; | ||||
|  | ||||
| import android.graphics.Paint; | ||||
| import android.text.Layout; | ||||
| import android.text.TextUtils; | ||||
| import android.text.method.LinkMovementMethod; | ||||
| import android.text.style.URLSpan; | ||||
| import android.text.util.Linkify; | ||||
| import android.util.Log; | ||||
| import android.view.View; | ||||
| import android.view.ViewGroup; | ||||
| @@ -11,27 +12,36 @@ import android.widget.ImageView; | ||||
| import android.widget.RelativeLayout; | ||||
| import android.widget.TextView; | ||||
|  | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.appcompat.app.AppCompatActivity; | ||||
| import androidx.core.text.util.LinkifyCompat; | ||||
| import androidx.core.text.HtmlCompat; | ||||
|  | ||||
| import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.error.ErrorUtil; | ||||
| import org.schabi.newpipe.extractor.InfoItem; | ||||
| import org.schabi.newpipe.extractor.NewPipe; | ||||
| import org.schabi.newpipe.extractor.ServiceList; | ||||
| import org.schabi.newpipe.extractor.StreamingService; | ||||
| import org.schabi.newpipe.extractor.comments.CommentsInfoItem; | ||||
| import org.schabi.newpipe.extractor.exceptions.ExtractionException; | ||||
| import org.schabi.newpipe.extractor.stream.Description; | ||||
| import org.schabi.newpipe.info_list.InfoItemBuilder; | ||||
| import org.schabi.newpipe.local.history.HistoryRecordManager; | ||||
| import org.schabi.newpipe.util.CommentTextOnTouchListener; | ||||
| import org.schabi.newpipe.util.DeviceUtils; | ||||
| import org.schabi.newpipe.util.Localization; | ||||
| import org.schabi.newpipe.util.NavigationHelper; | ||||
| import org.schabi.newpipe.util.PicassoHelper; | ||||
| import org.schabi.newpipe.util.external_communication.ShareUtils; | ||||
| import org.schabi.newpipe.util.external_communication.TimestampExtractor; | ||||
| import org.schabi.newpipe.util.text.CommentTextOnTouchListener; | ||||
| import org.schabi.newpipe.util.text.TextLinkifier; | ||||
|  | ||||
| import java.util.Objects; | ||||
| import java.util.function.Consumer; | ||||
|  | ||||
| import io.reactivex.rxjava3.disposables.CompositeDisposable; | ||||
|  | ||||
| public class CommentsMiniInfoItemHolder extends InfoItemHolder { | ||||
|     private static final String TAG = "CommentsMiniIIHolder"; | ||||
|     private static final String ELLIPSIS = "…"; | ||||
|  | ||||
|     private static final int COMMENT_DEFAULT_LINES = 2; | ||||
|     private static final int COMMENT_EXPANDED_LINES = 1000; | ||||
| @@ -39,13 +49,18 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { | ||||
|     private final int commentHorizontalPadding; | ||||
|     private final int commentVerticalPadding; | ||||
|  | ||||
|     private final Paint paintAtContentSize; | ||||
|     private final float ellipsisWidthPx; | ||||
|  | ||||
|     private final RelativeLayout itemRoot; | ||||
|     private final ImageView itemThumbnailView; | ||||
|     private final TextView itemContentView; | ||||
|     private final TextView itemLikesCountView; | ||||
|     private final TextView itemPublishedTime; | ||||
|  | ||||
|     private String commentText; | ||||
|     private final CompositeDisposable disposables = new CompositeDisposable(); | ||||
|     private Description commentText; | ||||
|     private StreamingService streamService; | ||||
|     private String streamUrl; | ||||
|  | ||||
|     CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, | ||||
| @@ -62,6 +77,10 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { | ||||
|                 .getResources().getDimension(R.dimen.comments_horizontal_padding); | ||||
|         commentVerticalPadding = (int) infoItemBuilder.getContext() | ||||
|                 .getResources().getDimension(R.dimen.comments_vertical_padding); | ||||
|  | ||||
|         paintAtContentSize = new Paint(); | ||||
|         paintAtContentSize.setTextSize(itemContentView.getTextSize()); | ||||
|         ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS); | ||||
|     } | ||||
|  | ||||
|     public CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, | ||||
| @@ -91,18 +110,20 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { | ||||
|  | ||||
|         itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item)); | ||||
|  | ||||
|         streamUrl = item.getUrl(); | ||||
|  | ||||
|         itemContentView.setLines(COMMENT_DEFAULT_LINES); | ||||
|         commentText = item.getCommentText(); | ||||
|         itemContentView.setText(commentText, TextView.BufferType.SPANNABLE); | ||||
|         itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE); | ||||
|  | ||||
|         if (itemContentView.getLineCount() == 0) { | ||||
|             itemContentView.post(this::ellipsize); | ||||
|         } else { | ||||
|             ellipsize(); | ||||
|         try { | ||||
|             streamService = NewPipe.getService(item.getServiceId()); | ||||
|         } catch (final ExtractionException e) { | ||||
|             // should never happen | ||||
|             ErrorUtil.showUiErrorSnackbar(itemBuilder.getContext(), "Getting StreamingService", e); | ||||
|             Log.w(TAG, "Cannot obtain service from comment service id, defaulting to YouTube", e); | ||||
|             streamService = ServiceList.YouTube; | ||||
|         } | ||||
|         streamUrl = item.getUrl(); | ||||
|         commentText = item.getCommentText(); | ||||
|         ellipsize(); | ||||
|  | ||||
|         //noinspection ClickableViewAccessibility | ||||
|         itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE); | ||||
|  | ||||
|         if (item.getLikeCount() >= 0) { | ||||
|             itemLikesCountView.setText( | ||||
| @@ -132,7 +153,8 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { | ||||
|             if (DeviceUtils.isTv(itemBuilder.getContext())) { | ||||
|                 openCommentAuthor(item); | ||||
|             } else { | ||||
|                 ShareUtils.copyToClipboard(itemBuilder.getContext(), commentText); | ||||
|                 ShareUtils.copyToClipboard(itemBuilder.getContext(), | ||||
|                         itemContentView.getText().toString()); | ||||
|             } | ||||
|             return true; | ||||
|         }); | ||||
| @@ -172,7 +194,7 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { | ||||
|         return urls != null && urls.length != 0; | ||||
|     } | ||||
|  | ||||
|     private void determineLinkFocus() { | ||||
|     private void determineMovementMethod() { | ||||
|         if (shouldFocusLinks()) { | ||||
|             allowLinkFocus(); | ||||
|         } else { | ||||
| @@ -181,63 +203,73 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { | ||||
|     } | ||||
|  | ||||
|     private void ellipsize() { | ||||
|         boolean hasEllipsis = false; | ||||
|         itemContentView.setMaxLines(COMMENT_EXPANDED_LINES); | ||||
|         linkifyCommentContentView(v -> { | ||||
|             boolean hasEllipsis = false; | ||||
|  | ||||
|         if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) { | ||||
|             final int endOfLastLine = itemContentView | ||||
|                     .getLayout() | ||||
|                     .getLineEnd(COMMENT_DEFAULT_LINES - 1); | ||||
|             int end = itemContentView.getText().toString().lastIndexOf(' ', endOfLastLine - 2); | ||||
|             if (end == -1) { | ||||
|                 end = Math.max(endOfLastLine - 2, 0); | ||||
|             if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) { | ||||
|                 // Note that converting to String removes spans (i.e. links), but that's something | ||||
|                 // we actually want since when the text is ellipsized we want all clicks on the | ||||
|                 // comment to expand the comment, not to open links. | ||||
|                 final String text = itemContentView.getText().toString(); | ||||
|  | ||||
|                 final Layout layout = itemContentView.getLayout(); | ||||
|                 final float lineWidth = layout.getLineWidth(COMMENT_DEFAULT_LINES - 1); | ||||
|                 final float layoutWidth = layout.getWidth(); | ||||
|                 final int lineStart = layout.getLineStart(COMMENT_DEFAULT_LINES - 1); | ||||
|                 final int lineEnd = layout.getLineEnd(COMMENT_DEFAULT_LINES - 1); | ||||
|  | ||||
|                 // remove characters up until there is enough space for the ellipsis | ||||
|                 // (also summing 2 more pixels, just to be sure to avoid float rounding errors) | ||||
|                 int end = lineEnd; | ||||
|                 float removedCharactersWidth = 0.0f; | ||||
|                 while (lineWidth - removedCharactersWidth + ellipsisWidthPx + 2.0f > layoutWidth | ||||
|                         && end >= lineStart) { | ||||
|                     end -= 1; | ||||
|                     // recalculate each time to account for ligatures or other similar things | ||||
|                     removedCharactersWidth = paintAtContentSize.measureText( | ||||
|                             text.substring(end, lineEnd)); | ||||
|                 } | ||||
|  | ||||
|                 // remove trailing spaces and newlines | ||||
|                 while (end > 0 && Character.isWhitespace(text.charAt(end - 1))) { | ||||
|                     end -= 1; | ||||
|                 } | ||||
|  | ||||
|                 final String newVal = text.substring(0, end) + ELLIPSIS; | ||||
|                 itemContentView.setText(newVal); | ||||
|                 hasEllipsis = true; | ||||
|             } | ||||
|             final String newVal = itemContentView.getText().subSequence(0, end) + " …"; | ||||
|             itemContentView.setText(newVal); | ||||
|             hasEllipsis = true; | ||||
|         } | ||||
|  | ||||
|         linkify(); | ||||
|  | ||||
|         if (hasEllipsis) { | ||||
|             denyLinkFocus(); | ||||
|         } else { | ||||
|             determineLinkFocus(); | ||||
|         } | ||||
|             itemContentView.setMaxLines(COMMENT_DEFAULT_LINES); | ||||
|             if (hasEllipsis) { | ||||
|                 denyLinkFocus(); | ||||
|             } else { | ||||
|                 determineMovementMethod(); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     private void toggleEllipsize() { | ||||
|         if (itemContentView.getText().toString().equals(commentText)) { | ||||
|             if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) { | ||||
|                 ellipsize(); | ||||
|             } | ||||
|         } else { | ||||
|         final CharSequence text = itemContentView.getText(); | ||||
|         if (text.charAt(text.length() - 1) == ELLIPSIS.charAt(0)) { | ||||
|             expand(); | ||||
|         } else if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) { | ||||
|             ellipsize(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void expand() { | ||||
|         itemContentView.setMaxLines(COMMENT_EXPANDED_LINES); | ||||
|         itemContentView.setText(commentText); | ||||
|         linkify(); | ||||
|         determineLinkFocus(); | ||||
|         linkifyCommentContentView(v -> determineMovementMethod()); | ||||
|     } | ||||
|  | ||||
|     private void linkify() { | ||||
|         LinkifyCompat.addLinks(itemContentView, Linkify.WEB_URLS); | ||||
|         LinkifyCompat.addLinks(itemContentView, TimestampExtractor.TIMESTAMPS_PATTERN, null, null, | ||||
|                 (match, url) -> { | ||||
|                     try { | ||||
|                         final var timestampMatch = TimestampExtractor | ||||
|                                 .getTimestampFromMatcher(match, commentText); | ||||
|                         if (timestampMatch == null) { | ||||
|                             return url; | ||||
|                         } | ||||
|                         return streamUrl + url.replace(Objects.requireNonNull(match.group(0)), | ||||
|                                 "#timestamp=" + timestampMatch.seconds()); | ||||
|                     } catch (final Exception ex) { | ||||
|                         Log.e(TAG, "Unable to process url='" + url + "' as timestampLink", ex); | ||||
|                         return url; | ||||
|                     } | ||||
|                 }); | ||||
|     private void linkifyCommentContentView(@Nullable final Consumer<TextView> onCompletion) { | ||||
|         disposables.clear(); | ||||
|         if (commentText != null) { | ||||
|             TextLinkifier.fromDescription(itemContentView, commentText, | ||||
|                     HtmlCompat.FROM_HTML_MODE_LEGACY, streamService, streamUrl, disposables, | ||||
|                     onCompletion); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,17 @@ | ||||
| package org.schabi.newpipe.info_list.holder; | ||||
|  | ||||
| import android.view.ViewGroup; | ||||
|  | ||||
| import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.info_list.InfoItemBuilder; | ||||
|  | ||||
| /** | ||||
|  * Playlist card layout. | ||||
|  */ | ||||
| public class PlaylistCardInfoItemHolder extends PlaylistMiniInfoItemHolder { | ||||
|  | ||||
|     public PlaylistCardInfoItemHolder(final InfoItemBuilder infoItemBuilder, | ||||
|                                       final ViewGroup parent) { | ||||
|         super(infoItemBuilder, R.layout.list_playlist_card_item, parent); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,16 @@ | ||||
| package org.schabi.newpipe.info_list.holder; | ||||
|  | ||||
| import android.view.ViewGroup; | ||||
|  | ||||
| import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.info_list.InfoItemBuilder; | ||||
|  | ||||
| /** | ||||
|  * Card layout for stream. | ||||
|  */ | ||||
| public class StreamCardInfoItemHolder extends StreamInfoItemHolder { | ||||
|  | ||||
|     public StreamCardInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { | ||||
|         super(infoItemBuilder, R.layout.list_stream_card_item, parent); | ||||
|     } | ||||
| } | ||||
| @@ -22,10 +22,11 @@ import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.databinding.PignateFooterBinding; | ||||
| import org.schabi.newpipe.fragments.BaseStateFragment; | ||||
| import org.schabi.newpipe.fragments.list.ListViewContract; | ||||
| import org.schabi.newpipe.info_list.ItemViewMode; | ||||
|  | ||||
| import static org.schabi.newpipe.ktx.ViewUtils.animate; | ||||
| import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; | ||||
| import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout; | ||||
| import static org.schabi.newpipe.util.ThemeHelper.getItemViewMode; | ||||
|  | ||||
| /** | ||||
|  * This fragment is design to be used with persistent data such as | ||||
| @@ -77,16 +78,23 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I> | ||||
|         super.onResume(); | ||||
|         if (updateFlags != 0) { | ||||
|             if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) { | ||||
|                 final boolean useGrid = shouldUseGridLayout(requireContext()); | ||||
|                 itemsList.setLayoutManager( | ||||
|                         useGrid ? getGridLayoutManager() : getListLayoutManager()); | ||||
|                 itemListAdapter.setUseGridVariant(useGrid); | ||||
|                 itemListAdapter.notifyDataSetChanged(); | ||||
|                 refreshItemViewMode(); | ||||
|             } | ||||
|             updateFlags = 0; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Updates the item view mode based on user preference. | ||||
|      */ | ||||
|     private void refreshItemViewMode() { | ||||
|         final ItemViewMode itemViewMode = getItemViewMode(requireContext()); | ||||
|         itemsList.setLayoutManager((itemViewMode == ItemViewMode.GRID) | ||||
|                 ? getGridLayoutManager() : getListLayoutManager()); | ||||
|         itemListAdapter.setItemViewMode(itemViewMode); | ||||
|         itemListAdapter.notifyDataSetChanged(); | ||||
|     } | ||||
|  | ||||
|     /*////////////////////////////////////////////////////////////////////////// | ||||
|     // Lifecycle - View | ||||
|     //////////////////////////////////////////////////////////////////////////*/ | ||||
| @@ -104,8 +112,7 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I> | ||||
|         final Resources resources = activity.getResources(); | ||||
|         int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width); | ||||
|         width += (24 * resources.getDisplayMetrics().density); | ||||
|         final int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels | ||||
|                 / (double) width); | ||||
|         final int spanCount = Math.floorDiv(resources.getDisplayMetrics().widthPixels, width); | ||||
|         final GridLayoutManager lm = new GridLayoutManager(activity, spanCount); | ||||
|         lm.setSpanSizeLookup(itemListAdapter.getSpanSizeLookup(spanCount)); | ||||
|         return lm; | ||||
| @@ -121,11 +128,9 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I> | ||||
|  | ||||
|         itemListAdapter = new LocalItemListAdapter(activity); | ||||
|  | ||||
|         final boolean useGrid = shouldUseGridLayout(requireContext()); | ||||
|         itemsList = rootView.findViewById(R.id.items_list); | ||||
|         itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager()); | ||||
|         refreshItemViewMode(); | ||||
|  | ||||
|         itemListAdapter.setUseGridVariant(useGrid); | ||||
|         headerRootBinding = getListHeader(); | ||||
|         if (headerRootBinding != null) { | ||||
|             itemListAdapter.setHeader(headerRootBinding.getRoot()); | ||||
| @@ -256,7 +261,7 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I> | ||||
|     @Override | ||||
|     public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, | ||||
|                                           final String key) { | ||||
|         if (key.equals(getString(R.string.list_view_mode_key))) { | ||||
|         if (getString(R.string.list_view_mode_key).equals(key)) { | ||||
|             updateFlags |= LIST_MODE_UPDATE_FLAG; | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -12,14 +12,19 @@ import androidx.recyclerview.widget.RecyclerView; | ||||
|  | ||||
| import org.schabi.newpipe.database.LocalItem; | ||||
| import org.schabi.newpipe.database.stream.model.StreamStateEntity; | ||||
| import org.schabi.newpipe.info_list.ItemViewMode; | ||||
| import org.schabi.newpipe.local.history.HistoryRecordManager; | ||||
| import org.schabi.newpipe.local.holder.LocalItemHolder; | ||||
| import org.schabi.newpipe.local.holder.LocalPlaylistCardItemHolder; | ||||
| import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder; | ||||
| import org.schabi.newpipe.local.holder.LocalPlaylistItemHolder; | ||||
| import org.schabi.newpipe.local.holder.LocalPlaylistStreamCardItemHolder; | ||||
| import org.schabi.newpipe.local.holder.LocalPlaylistStreamGridItemHolder; | ||||
| import org.schabi.newpipe.local.holder.LocalPlaylistStreamItemHolder; | ||||
| import org.schabi.newpipe.local.holder.LocalStatisticStreamCardItemHolder; | ||||
| import org.schabi.newpipe.local.holder.LocalStatisticStreamGridItemHolder; | ||||
| import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder; | ||||
| import org.schabi.newpipe.local.holder.RemotePlaylistCardItemHolder; | ||||
| import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder; | ||||
| import org.schabi.newpipe.local.holder.RemotePlaylistItemHolder; | ||||
| import org.schabi.newpipe.util.FallbackViewHolder; | ||||
| @@ -61,11 +66,17 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View | ||||
|     private static final int STREAM_STATISTICS_HOLDER_TYPE = 0x1000; | ||||
|     private static final int STREAM_PLAYLIST_HOLDER_TYPE = 0x1001; | ||||
|     private static final int STREAM_STATISTICS_GRID_HOLDER_TYPE = 0x1002; | ||||
|     private static final int STREAM_STATISTICS_CARD_HOLDER_TYPE = 0x1003; | ||||
|     private static final int STREAM_PLAYLIST_GRID_HOLDER_TYPE = 0x1004; | ||||
|     private static final int STREAM_PLAYLIST_CARD_HOLDER_TYPE = 0x1005; | ||||
|  | ||||
|     private static final int LOCAL_PLAYLIST_HOLDER_TYPE = 0x2000; | ||||
|     private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x2001; | ||||
|     private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2002; | ||||
|     private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x2004; | ||||
|     private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2001; | ||||
|     private static final int LOCAL_PLAYLIST_CARD_HOLDER_TYPE = 0x2002; | ||||
|  | ||||
|     private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x3000; | ||||
|     private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x3001; | ||||
|     private static final int REMOTE_PLAYLIST_CARD_HOLDER_TYPE = 0x3002; | ||||
|  | ||||
|     private final LocalItemBuilder localItemBuilder; | ||||
|     private final ArrayList<LocalItem> localItems; | ||||
| @@ -73,9 +84,9 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View | ||||
|     private final DateTimeFormatter dateTimeFormatter; | ||||
|  | ||||
|     private boolean showFooter = false; | ||||
|     private boolean useGridVariant = false; | ||||
|     private View header = null; | ||||
|     private View footer = null; | ||||
|     private ItemViewMode itemViewMode = ItemViewMode.LIST; | ||||
|  | ||||
|     public LocalItemListAdapter(final Context context) { | ||||
|         recordManager = new HistoryRecordManager(context); | ||||
| @@ -165,8 +176,8 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View | ||||
|         notifyDataSetChanged(); | ||||
|     } | ||||
|  | ||||
|     public void setUseGridVariant(final boolean useGridVariant) { | ||||
|         this.useGridVariant = useGridVariant; | ||||
|     public void setItemViewMode(final ItemViewMode itemViewMode) { | ||||
|         this.itemViewMode = itemViewMode; | ||||
|     } | ||||
|  | ||||
|     public void setHeader(final View header) { | ||||
| @@ -244,21 +255,39 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View | ||||
|             return FOOTER_TYPE; | ||||
|         } | ||||
|         final LocalItem item = localItems.get(position); | ||||
|  | ||||
|         switch (item.getLocalItemType()) { | ||||
|             case PLAYLIST_LOCAL_ITEM: | ||||
|                 return useGridVariant | ||||
|                         ? LOCAL_PLAYLIST_GRID_HOLDER_TYPE : LOCAL_PLAYLIST_HOLDER_TYPE; | ||||
|                 if (itemViewMode == ItemViewMode.CARD) { | ||||
|                     return LOCAL_PLAYLIST_CARD_HOLDER_TYPE; | ||||
|                 } else if (itemViewMode == ItemViewMode.GRID) { | ||||
|                     return LOCAL_PLAYLIST_GRID_HOLDER_TYPE; | ||||
|                 } else { | ||||
|                     return LOCAL_PLAYLIST_HOLDER_TYPE; | ||||
|                 } | ||||
|             case PLAYLIST_REMOTE_ITEM: | ||||
|                 return useGridVariant | ||||
|                         ? REMOTE_PLAYLIST_GRID_HOLDER_TYPE : REMOTE_PLAYLIST_HOLDER_TYPE; | ||||
|  | ||||
|                 if (itemViewMode == ItemViewMode.CARD) { | ||||
|                     return REMOTE_PLAYLIST_CARD_HOLDER_TYPE; | ||||
|                 } else if (itemViewMode == ItemViewMode.GRID) { | ||||
|                     return REMOTE_PLAYLIST_GRID_HOLDER_TYPE; | ||||
|                 } else { | ||||
|                     return REMOTE_PLAYLIST_HOLDER_TYPE; | ||||
|                 } | ||||
|             case PLAYLIST_STREAM_ITEM: | ||||
|                 return useGridVariant | ||||
|                         ? STREAM_PLAYLIST_GRID_HOLDER_TYPE : STREAM_PLAYLIST_HOLDER_TYPE; | ||||
|                 if (itemViewMode == ItemViewMode.CARD) { | ||||
|                     return STREAM_PLAYLIST_CARD_HOLDER_TYPE; | ||||
|                 } else if (itemViewMode == ItemViewMode.GRID) { | ||||
|                     return STREAM_PLAYLIST_GRID_HOLDER_TYPE; | ||||
|                 } else { | ||||
|                     return STREAM_PLAYLIST_HOLDER_TYPE; | ||||
|                 } | ||||
|             case STATISTIC_STREAM_ITEM: | ||||
|                 return useGridVariant | ||||
|                         ? STREAM_STATISTICS_GRID_HOLDER_TYPE : STREAM_STATISTICS_HOLDER_TYPE; | ||||
|                 if (itemViewMode == ItemViewMode.CARD) { | ||||
|                     return STREAM_STATISTICS_CARD_HOLDER_TYPE; | ||||
|                 } else if (itemViewMode == ItemViewMode.GRID) { | ||||
|                     return STREAM_STATISTICS_GRID_HOLDER_TYPE; | ||||
|                 } else { | ||||
|                     return STREAM_STATISTICS_HOLDER_TYPE; | ||||
|                 } | ||||
|             default: | ||||
|                 Log.e(TAG, "No holder type has been considered for item: [" | ||||
|                         + item.getLocalItemType() + "]"); | ||||
| @@ -283,18 +312,26 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View | ||||
|                 return new LocalPlaylistItemHolder(localItemBuilder, parent); | ||||
|             case LOCAL_PLAYLIST_GRID_HOLDER_TYPE: | ||||
|                 return new LocalPlaylistGridItemHolder(localItemBuilder, parent); | ||||
|             case LOCAL_PLAYLIST_CARD_HOLDER_TYPE: | ||||
|                 return new LocalPlaylistCardItemHolder(localItemBuilder, parent); | ||||
|             case REMOTE_PLAYLIST_HOLDER_TYPE: | ||||
|                 return new RemotePlaylistItemHolder(localItemBuilder, parent); | ||||
|             case REMOTE_PLAYLIST_GRID_HOLDER_TYPE: | ||||
|                 return new RemotePlaylistGridItemHolder(localItemBuilder, parent); | ||||
|             case REMOTE_PLAYLIST_CARD_HOLDER_TYPE: | ||||
|                 return new RemotePlaylistCardItemHolder(localItemBuilder, parent); | ||||
|             case STREAM_PLAYLIST_HOLDER_TYPE: | ||||
|                 return new LocalPlaylistStreamItemHolder(localItemBuilder, parent); | ||||
|             case STREAM_PLAYLIST_GRID_HOLDER_TYPE: | ||||
|                 return new LocalPlaylistStreamGridItemHolder(localItemBuilder, parent); | ||||
|             case STREAM_PLAYLIST_CARD_HOLDER_TYPE: | ||||
|                 return new LocalPlaylistStreamCardItemHolder(localItemBuilder, parent); | ||||
|             case STREAM_STATISTICS_HOLDER_TYPE: | ||||
|                 return new LocalStatisticStreamItemHolder(localItemBuilder, parent); | ||||
|             case STREAM_STATISTICS_GRID_HOLDER_TYPE: | ||||
|                 return new LocalStatisticStreamGridItemHolder(localItemBuilder, parent); | ||||
|             case STREAM_STATISTICS_CARD_HOLDER_TYPE: | ||||
|                 return new LocalStatisticStreamCardItemHolder(localItemBuilder, parent); | ||||
|             default: | ||||
|                 Log.e(TAG, "No view type has been considered for holder: [" + type + "]"); | ||||
|                 return new FallbackViewHolder(new View(parent.getContext())); | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| package org.schabi.newpipe.local.bookmark; | ||||
|  | ||||
| import android.content.DialogInterface; | ||||
| import android.os.Bundle; | ||||
| import android.os.Parcelable; | ||||
| import android.text.InputType; | ||||
| @@ -31,6 +32,7 @@ import org.schabi.newpipe.local.playlist.RemotePlaylistManager; | ||||
| import org.schabi.newpipe.util.NavigationHelper; | ||||
| import org.schabi.newpipe.util.OnClickGesture; | ||||
|  | ||||
| import java.util.ArrayList; | ||||
| import java.util.List; | ||||
|  | ||||
| import icepick.State; | ||||
| @@ -256,6 +258,41 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL | ||||
|     } | ||||
|  | ||||
|     private void showLocalDialog(final PlaylistMetadataEntry selectedItem) { | ||||
|         final String rename = getString(R.string.rename); | ||||
|         final String delete = getString(R.string.delete); | ||||
|         final String unsetThumbnail = getString(R.string.unset_playlist_thumbnail); | ||||
|         final boolean isThumbnailPermanent = localPlaylistManager | ||||
|                 .getIsPlaylistThumbnailPermanent(selectedItem.uid); | ||||
|  | ||||
|         final AlertDialog.Builder builder = new AlertDialog.Builder(activity); | ||||
|  | ||||
|         final ArrayList<String> items = new ArrayList<>(); | ||||
|         items.add(rename); | ||||
|         items.add(delete); | ||||
|         if (isThumbnailPermanent) { | ||||
|             items.add(unsetThumbnail); | ||||
|         } | ||||
|  | ||||
|         final DialogInterface.OnClickListener action = (d, index) -> { | ||||
|             if (items.get(index).equals(rename)) { | ||||
|                 showRenameDialog(selectedItem); | ||||
|             } else if (items.get(index).equals(delete)) { | ||||
|                 showDeleteDialog(selectedItem.name, | ||||
|                         localPlaylistManager.deletePlaylist(selectedItem.uid)); | ||||
|             } else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) { | ||||
|                 final String thumbnailUrl = localPlaylistManager | ||||
|                         .getAutomaticPlaylistThumbnail(selectedItem.uid); | ||||
|                 localPlaylistManager | ||||
|                         .changePlaylistThumbnail(selectedItem.uid, thumbnailUrl, false) | ||||
|                         .observeOn(AndroidSchedulers.mainThread()) | ||||
|                         .subscribe(); | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         builder.setItems(items.toArray(new String[0]), action).create().show(); | ||||
|     } | ||||
|  | ||||
|     private void showRenameDialog(final PlaylistMetadataEntry selectedItem) { | ||||
|         final DialogEditTextBinding dialogBinding = | ||||
|                 DialogEditTextBinding.inflate(getLayoutInflater()); | ||||
|         dialogBinding.dialogEditText.setHint(R.string.name); | ||||
| @@ -269,11 +306,6 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL | ||||
|                                 selectedItem.uid, | ||||
|                                 dialogBinding.dialogEditText.getText().toString())) | ||||
|                 .setNegativeButton(R.string.cancel, null) | ||||
|                 .setNeutralButton(R.string.delete, (dialog, which) -> { | ||||
|                     showDeleteDialog(selectedItem.name, | ||||
|                             localPlaylistManager.deletePlaylist(selectedItem.uid)); | ||||
|                     dialog.dismiss(); | ||||
|                 }) | ||||
|                 .create() | ||||
|                 .show(); | ||||
|     } | ||||
|   | ||||
| @@ -134,7 +134,7 @@ public final class PlaylistAppendDialog extends PlaylistDialog { | ||||
|         if (playlist.thumbnailUrl | ||||
|                 .equals("drawable://" + R.drawable.placeholder_thumbnail_playlist)) { | ||||
|             playlistDisposables.add(manager | ||||
|                     .changePlaylistThumbnail(playlist.uid, streams.get(0).getThumbnailUrl()) | ||||
|                     .changePlaylistThumbnail(playlist.uid, streams.get(0).getThumbnailUrl(), false) | ||||
|                     .observeOn(AndroidSchedulers.mainThread()) | ||||
|                     .subscribe(ignored -> successToast.show())); | ||||
|         } | ||||
|   | ||||
| @@ -36,7 +36,6 @@ import android.view.MenuItem | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import android.widget.Button | ||||
| import androidx.annotation.Nullable | ||||
| import androidx.appcompat.app.AlertDialog | ||||
| import androidx.appcompat.content.res.AppCompatResources | ||||
| import androidx.core.content.edit | ||||
| @@ -69,6 +68,7 @@ import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException | ||||
| import org.schabi.newpipe.extractor.stream.StreamInfoItem | ||||
| import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty | ||||
| import org.schabi.newpipe.fragments.BaseStateFragment | ||||
| import org.schabi.newpipe.info_list.ItemViewMode | ||||
| import org.schabi.newpipe.info_list.dialog.InfoItemDialog | ||||
| import org.schabi.newpipe.ktx.animate | ||||
| import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling | ||||
| @@ -80,6 +80,7 @@ import org.schabi.newpipe.util.DeviceUtils | ||||
| import org.schabi.newpipe.util.Localization | ||||
| import org.schabi.newpipe.util.NavigationHelper | ||||
| import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams | ||||
| import org.schabi.newpipe.util.ThemeHelper.getItemViewMode | ||||
| import org.schabi.newpipe.util.ThemeHelper.resolveDrawable | ||||
| import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout | ||||
| import java.time.OffsetDateTime | ||||
| @@ -120,7 +121,7 @@ class FeedFragment : BaseStateFragment<FeedState>() { | ||||
|         groupName = arguments?.getString(KEY_GROUP_NAME) ?: "" | ||||
|  | ||||
|         onSettingsChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> | ||||
|             if (key.equals(getString(R.string.list_view_mode_key))) { | ||||
|             if (getString(R.string.list_view_mode_key).equals(key)) { | ||||
|                 updateListViewModeOnResume = true | ||||
|             } | ||||
|         } | ||||
| @@ -416,11 +417,10 @@ class FeedFragment : BaseStateFragment<FeedState>() { | ||||
|  | ||||
|     @SuppressLint("StringFormatMatches") | ||||
|     private fun handleLoadedState(loadedState: FeedState.LoadedState) { | ||||
|  | ||||
|         val itemVersion = if (shouldUseGridLayout(context)) { | ||||
|             StreamItem.ItemVersion.GRID | ||||
|         } else { | ||||
|             StreamItem.ItemVersion.NORMAL | ||||
|         val itemVersion = when (getItemViewMode(requireContext())) { | ||||
|             ItemViewMode.GRID -> StreamItem.ItemVersion.GRID | ||||
|             ItemViewMode.CARD -> StreamItem.ItemVersion.CARD | ||||
|             else -> StreamItem.ItemVersion.NORMAL | ||||
|         } | ||||
|         loadedState.items.forEach { it.itemVersion = itemVersion } | ||||
|  | ||||
| @@ -499,7 +499,7 @@ class FeedFragment : BaseStateFragment<FeedState>() { | ||||
|  | ||||
|     private fun handleFeedNotAvailable( | ||||
|         subscriptionEntity: SubscriptionEntity, | ||||
|         @Nullable cause: Throwable?, | ||||
|         cause: Throwable?, | ||||
|         nextItemsErrors: List<Throwable> | ||||
|     ) { | ||||
|         val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) | ||||
|   | ||||
| @@ -42,12 +42,13 @@ data class StreamItem( | ||||
|  | ||||
|     override fun getId(): Long = stream.uid | ||||
|  | ||||
|     enum class ItemVersion { NORMAL, MINI, GRID } | ||||
|     enum class ItemVersion { NORMAL, MINI, GRID, CARD } | ||||
|  | ||||
|     override fun getLayout(): Int = when (itemVersion) { | ||||
|         ItemVersion.NORMAL -> R.layout.list_stream_item | ||||
|         ItemVersion.MINI -> R.layout.list_stream_mini_item | ||||
|         ItemVersion.GRID -> R.layout.list_stream_grid_item | ||||
|         ItemVersion.CARD -> R.layout.list_stream_card_item | ||||
|     } | ||||
|  | ||||
|     override fun initializeViewBinding(view: View) = ListStreamItemBinding.bind(view) | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| 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.graphics.Bitmap | ||||
| @@ -20,6 +19,7 @@ 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.PendingIntentCompat | ||||
| import org.schabi.newpipe.util.PicassoHelper | ||||
|  | ||||
| /** | ||||
| @@ -70,16 +70,13 @@ class NotificationHelper(val context: Context) { | ||||
|  | ||||
|         // open the channel page when clicking on the notification | ||||
|         builder.setContentIntent( | ||||
|             PendingIntent.getActivity( | ||||
|             PendingIntentCompat.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 | ||||
|                 0 | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|   | ||||
| @@ -19,7 +19,6 @@ | ||||
|  | ||||
| package org.schabi.newpipe.local.feed.service | ||||
|  | ||||
| import android.app.PendingIntent | ||||
| import android.app.Service | ||||
| import android.content.BroadcastReceiver | ||||
| import android.content.Context | ||||
| @@ -43,6 +42,7 @@ import org.schabi.newpipe.extractor.ListInfo | ||||
| import org.schabi.newpipe.extractor.stream.StreamInfoItem | ||||
| import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent | ||||
| import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent | ||||
| import org.schabi.newpipe.util.PendingIntentCompat | ||||
| import java.util.concurrent.TimeUnit | ||||
|  | ||||
| class FeedLoadService : Service() { | ||||
| @@ -152,12 +152,8 @@ class FeedLoadService : Service() { | ||||
|     private lateinit var notificationBuilder: NotificationCompat.Builder | ||||
|  | ||||
|     private fun createNotification(): NotificationCompat.Builder { | ||||
|         val cancelActionIntent = PendingIntent.getBroadcast( | ||||
|             this, | ||||
|             NOTIFICATION_ID, | ||||
|             Intent(ACTION_CANCEL), | ||||
|             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 | ||||
|         ) | ||||
|         val cancelActionIntent = | ||||
|             PendingIntentCompat.getBroadcast(this, NOTIFICATION_ID, Intent(ACTION_CANCEL), 0) | ||||
|  | ||||
|         return NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) | ||||
|             .setOngoing(true) | ||||
|   | ||||
| @@ -0,0 +1,17 @@ | ||||
| package org.schabi.newpipe.local.holder; | ||||
|  | ||||
| import android.view.ViewGroup; | ||||
|  | ||||
| import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.local.LocalItemBuilder; | ||||
|  | ||||
| /** | ||||
|  * Playlist card layout. | ||||
|  */ | ||||
| public class LocalPlaylistCardItemHolder extends LocalPlaylistItemHolder { | ||||
|  | ||||
|     public LocalPlaylistCardItemHolder(final LocalItemBuilder infoItemBuilder, | ||||
|                                        final ViewGroup parent) { | ||||
|         super(infoItemBuilder, R.layout.list_playlist_card_item, parent); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,17 @@ | ||||
| package org.schabi.newpipe.local.holder; | ||||
|  | ||||
| import android.view.ViewGroup; | ||||
|  | ||||
| import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.local.LocalItemBuilder; | ||||
|  | ||||
| /** | ||||
|  * Local playlist stream UI. This also includes a handle to rearrange the videos. | ||||
|  */ | ||||
| public class LocalPlaylistStreamCardItemHolder extends LocalPlaylistStreamItemHolder { | ||||
|  | ||||
|     public LocalPlaylistStreamCardItemHolder(final LocalItemBuilder infoItemBuilder, | ||||
|                                              final ViewGroup parent) { | ||||
|         super(infoItemBuilder, R.layout.list_stream_playlist_card_item, parent); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,13 @@ | ||||
| package org.schabi.newpipe.local.holder; | ||||
|  | ||||
| import android.view.ViewGroup; | ||||
|  | ||||
| import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.local.LocalItemBuilder; | ||||
|  | ||||
| public class LocalStatisticStreamCardItemHolder extends LocalStatisticStreamItemHolder { | ||||
|     public LocalStatisticStreamCardItemHolder(final LocalItemBuilder infoItemBuilder, | ||||
|                                               final ViewGroup parent) { | ||||
|         super(infoItemBuilder, R.layout.list_stream_card_item, parent); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,17 @@ | ||||
| package org.schabi.newpipe.local.holder; | ||||
|  | ||||
| import android.view.ViewGroup; | ||||
|  | ||||
| import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.local.LocalItemBuilder; | ||||
|  | ||||
| /** | ||||
|  * Playlist card UI for list item. | ||||
|  */ | ||||
| public class RemotePlaylistCardItemHolder extends RemotePlaylistItemHolder { | ||||
|  | ||||
|     public RemotePlaylistCardItemHolder(final LocalItemBuilder infoItemBuilder, | ||||
|                                         final ViewGroup parent) { | ||||
|         super(infoItemBuilder, R.layout.list_playlist_card_item, parent); | ||||
|     } | ||||
| } | ||||
| @@ -404,7 +404,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt | ||||
|                 .firstElement() | ||||
|                 .zipWith(historyIdsMaybe, (playlist, historyStreamIds) -> { | ||||
|                     // Remove Watched, Functionality data | ||||
|                     final List<PlaylistStreamEntry> notWatchedItems = new ArrayList<>(); | ||||
|                     final List<PlaylistStreamEntry> itemsToKeep = new ArrayList<>(); | ||||
|                     final boolean isThumbnailPermanent = playlistManager | ||||
|                             .getIsPlaylistThumbnailPermanent(playlistId); | ||||
|                     boolean thumbnailVideoRemoved = false; | ||||
|  | ||||
|                     if (removePartiallyWatched) { | ||||
| @@ -413,8 +415,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt | ||||
|                                     playlistItem.getStreamId()); | ||||
|  | ||||
|                             if (indexInHistory < 0) { | ||||
|                                 notWatchedItems.add(playlistItem); | ||||
|                             } else if (!thumbnailVideoRemoved | ||||
|                                 itemsToKeep.add(playlistItem); | ||||
|                             } else if (!isThumbnailPermanent && !thumbnailVideoRemoved | ||||
|                                     && playlistManager.getPlaylistThumbnail(playlistId) | ||||
|                                     .equals(playlistItem.getStreamEntity().getThumbnailUrl())) { | ||||
|                                 thumbnailVideoRemoved = true; | ||||
| @@ -434,8 +436,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt | ||||
|  | ||||
|                             if (indexInHistory < 0 || (streamStateEntity != null | ||||
|                                     && !streamStateEntity.isFinished(duration))) { | ||||
|                                 notWatchedItems.add(playlistItem); | ||||
|                             } else if (!thumbnailVideoRemoved | ||||
|                                 itemsToKeep.add(playlistItem); | ||||
|                             } else if (!isThumbnailPermanent && !thumbnailVideoRemoved | ||||
|                                     && playlistManager.getPlaylistThumbnail(playlistId) | ||||
|                                     .equals(playlistItem.getStreamEntity().getThumbnailUrl())) { | ||||
|                                 thumbnailVideoRemoved = true; | ||||
| @@ -443,17 +445,17 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     return new Pair<>(notWatchedItems, thumbnailVideoRemoved); | ||||
|                     return new Pair<>(itemsToKeep, thumbnailVideoRemoved); | ||||
|                 }); | ||||
|  | ||||
|         disposables.add(streamsMaybe.subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribe(flow -> { | ||||
|                     final List<PlaylistStreamEntry> notWatchedItems = flow.first; | ||||
|                     final List<PlaylistStreamEntry> itemsToKeep = flow.first; | ||||
|                     final boolean thumbnailVideoRemoved = flow.second; | ||||
|  | ||||
|                     itemListAdapter.clearStreamItemList(); | ||||
|                     itemListAdapter.addItems(notWatchedItems); | ||||
|                     itemListAdapter.addItems(itemsToKeep); | ||||
|                     saveChanges(); | ||||
|  | ||||
|                     if (thumbnailVideoRemoved) { | ||||
| @@ -585,8 +587,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt | ||||
|         disposables.add(disposable); | ||||
|     } | ||||
|  | ||||
|     private void changeThumbnailUrl(final String thumbnailUrl) { | ||||
|         if (playlistManager == null) { | ||||
|     private void changeThumbnailUrl(final String thumbnailUrl, final boolean isPermanent) { | ||||
|         if (playlistManager == null || (!isPermanent && playlistManager | ||||
|                 .getIsPlaylistThumbnailPermanent(playlistId))) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
| @@ -600,7 +603,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt | ||||
|         } | ||||
|  | ||||
|         final Disposable disposable = playlistManager | ||||
|                 .changePlaylistThumbnail(playlistId, thumbnailUrl) | ||||
|                 .changePlaylistThumbnail(playlistId, thumbnailUrl, isPermanent) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribe(ignore -> successToast.show(), throwable -> | ||||
|                         showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, | ||||
| @@ -609,6 +612,10 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt | ||||
|     } | ||||
|  | ||||
|     private void updateThumbnailUrl() { | ||||
|         if (playlistManager.getIsPlaylistThumbnailPermanent(playlistId)) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         final String newThumbnailUrl; | ||||
|  | ||||
|         if (!itemListAdapter.getItemsList().isEmpty()) { | ||||
| @@ -618,7 +625,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt | ||||
|             newThumbnailUrl = "drawable://" + R.drawable.placeholder_thumbnail_playlist; | ||||
|         } | ||||
|  | ||||
|         changeThumbnailUrl(newThumbnailUrl); | ||||
|         changeThumbnailUrl(newThumbnailUrl, false); | ||||
|     } | ||||
|  | ||||
|     private void deleteItem(final PlaylistStreamEntry item) { | ||||
| @@ -786,7 +793,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt | ||||
|                     .setAction( | ||||
|                             StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL, | ||||
|                             (f, i) -> | ||||
|                                     changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl())) | ||||
|                                     changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl(), | ||||
|                                             true)) | ||||
|                     .setAction( | ||||
|                             StreamDialogDefaultEntry.DELETE, | ||||
|                             (f, i) -> deleteItem(item)) | ||||
|   | ||||
| @@ -2,6 +2,7 @@ package org.schabi.newpipe.local.playlist; | ||||
|  | ||||
| import androidx.annotation.Nullable; | ||||
|  | ||||
| import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.database.AppDatabase; | ||||
| import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; | ||||
| import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; | ||||
| @@ -41,7 +42,7 @@ public class LocalPlaylistManager { | ||||
|         } | ||||
|         final StreamEntity defaultStream = streams.get(0); | ||||
|         final PlaylistEntity newPlaylist = | ||||
|                 new PlaylistEntity(name, defaultStream.getThumbnailUrl()); | ||||
|                 new PlaylistEntity(name, defaultStream.getThumbnailUrl(), false); | ||||
|  | ||||
|         return Maybe.fromCallable(() -> database.runInTransaction(() -> | ||||
|                 upsertStreams(playlistTable.insert(newPlaylist), streams, 0)) | ||||
| @@ -96,21 +97,33 @@ public class LocalPlaylistManager { | ||||
|     } | ||||
|  | ||||
|     public Maybe<Integer> renamePlaylist(final long playlistId, final String name) { | ||||
|         return modifyPlaylist(playlistId, name, null); | ||||
|         return modifyPlaylist(playlistId, name, null, false); | ||||
|     } | ||||
|  | ||||
|     public Maybe<Integer> changePlaylistThumbnail(final long playlistId, | ||||
|                                                   final String thumbnailUrl) { | ||||
|         return modifyPlaylist(playlistId, null, thumbnailUrl); | ||||
|                                                   final String thumbnailUrl, | ||||
|                                                   final boolean isPermanent) { | ||||
|         return modifyPlaylist(playlistId, null, thumbnailUrl, isPermanent); | ||||
|     } | ||||
|  | ||||
|     public String getPlaylistThumbnail(final long playlistId) { | ||||
|         return playlistTable.getPlaylist(playlistId).blockingFirst().get(0).getThumbnailUrl(); | ||||
|     } | ||||
|  | ||||
|     public boolean getIsPlaylistThumbnailPermanent(final long playlistId) { | ||||
|         return playlistTable.getPlaylist(playlistId).blockingFirst().get(0) | ||||
|                 .getIsThumbnailPermanent(); | ||||
|     } | ||||
|  | ||||
|     public String getAutomaticPlaylistThumbnail(final long playlistId) { | ||||
|         final String def = "drawable://" + R.drawable.placeholder_thumbnail_playlist; | ||||
|         return playlistStreamTable.getAutomaticThumbnailUrl(playlistId, def).blockingFirst(); | ||||
|     } | ||||
|  | ||||
|     private Maybe<Integer> modifyPlaylist(final long playlistId, | ||||
|                                           @Nullable final String name, | ||||
|                                           @Nullable final String thumbnailUrl) { | ||||
|                                           @Nullable final String thumbnailUrl, | ||||
|                                           final boolean isPermanent) { | ||||
|         return playlistTable.getPlaylist(playlistId) | ||||
|                 .firstElement() | ||||
|                 .filter(playlistEntities -> !playlistEntities.isEmpty()) | ||||
| @@ -121,6 +134,7 @@ public class LocalPlaylistManager { | ||||
|                     } | ||||
|                     if (thumbnailUrl != null) { | ||||
|                         playlist.setThumbnailUrl(thumbnailUrl); | ||||
|                         playlist.setIsThumbnailPermanent(isPermanent); | ||||
|                     } | ||||
|                     return playlistTable.update(playlist); | ||||
|                 }).subscribeOn(Schedulers.io()); | ||||
|   | ||||
| @@ -51,7 +51,8 @@ enum class FeedGroupIcon( | ||||
|     WORLD(34, R.drawable.ic_public), | ||||
|     STAR(35, R.drawable.ic_stars), | ||||
|     SUN(36, R.drawable.ic_wb_sunny), | ||||
|     RSS(37, R.drawable.ic_rss_feed); | ||||
|     RSS(37, R.drawable.ic_rss_feed), | ||||
|     WHATS_NEW(38, R.drawable.ic_subscriptions); | ||||
|  | ||||
|     @DrawableRes | ||||
|     fun getDrawableRes(): Int { | ||||
|   | ||||
| @@ -41,7 +41,6 @@ import org.schabi.newpipe.local.subscription.SubscriptionViewModel.SubscriptionS | ||||
| import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog | ||||
| import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialog | ||||
| import org.schabi.newpipe.local.subscription.item.ChannelItem | ||||
| import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem | ||||
| import org.schabi.newpipe.local.subscription.item.FeedGroupAddNewGridItem | ||||
| import org.schabi.newpipe.local.subscription.item.FeedGroupAddNewItem | ||||
| import org.schabi.newpipe.local.subscription.item.FeedGroupCardGridItem | ||||
| @@ -49,6 +48,7 @@ import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem | ||||
| import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem | ||||
| import org.schabi.newpipe.local.subscription.item.GroupsHeader | ||||
| import org.schabi.newpipe.local.subscription.item.Header | ||||
| import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem | ||||
| import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService | ||||
| import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService | ||||
| import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE | ||||
| @@ -312,7 +312,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() { | ||||
|             groupAdapter.add(this) | ||||
|         } | ||||
|  | ||||
|         subscriptionsSection.setPlaceholder(EmptyPlaceholderItem()) | ||||
|         subscriptionsSection.setPlaceholder(ImportSubscriptionsHintPlaceholderItem()) | ||||
|         subscriptionsSection.setHideWhenEmpty(true) | ||||
|  | ||||
|         groupAdapter.add( | ||||
| @@ -433,10 +433,10 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() { | ||||
|                 clear() | ||||
|                 if (listViewMode) { | ||||
|                     add(FeedGroupAddNewItem()) | ||||
|                     add(FeedGroupCardItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.RSS)) | ||||
|                     add(FeedGroupCardItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.WHATS_NEW)) | ||||
|                 } else { | ||||
|                     add(FeedGroupAddNewGridItem()) | ||||
|                     add(FeedGroupCardGridItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.RSS)) | ||||
|                     add(FeedGroupCardGridItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.WHATS_NEW)) | ||||
|                 } | ||||
|                 addAll(groups) | ||||
|             } | ||||
|   | ||||
| @@ -35,7 +35,7 @@ import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState. | ||||
| import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.SubscriptionsPickerScreen | ||||
| import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.ProcessingEvent | ||||
| import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.SuccessEvent | ||||
| import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem | ||||
| import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem | ||||
| import org.schabi.newpipe.local.subscription.item.PickerIconItem | ||||
| import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem | ||||
| import org.schabi.newpipe.util.DeviceUtils | ||||
| @@ -338,7 +338,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable { | ||||
|  | ||||
|         if (subscriptions.isEmpty()) { | ||||
|             subscriptionEmptyFooter.clear() | ||||
|             subscriptionEmptyFooter.add(EmptyPlaceholderItem()) | ||||
|             subscriptionEmptyFooter.add(ImportSubscriptionsHintPlaceholderItem()) | ||||
|         } else { | ||||
|             subscriptionEmptyFooter.clear() | ||||
|         } | ||||
|   | ||||
| @@ -5,8 +5,11 @@ import com.xwray.groupie.viewbinding.BindableItem | ||||
| import org.schabi.newpipe.R | ||||
| import org.schabi.newpipe.databinding.ListEmptyViewBinding | ||||
| 
 | ||||
| class EmptyPlaceholderItem : BindableItem<ListEmptyViewBinding>() { | ||||
|     override fun getLayout(): Int = R.layout.list_empty_view | ||||
| /** | ||||
|  * When there are no subscriptions, show a hint to the user about how to import subscriptions | ||||
|  */ | ||||
| class ImportSubscriptionsHintPlaceholderItem : BindableItem<ListEmptyViewBinding>() { | ||||
|     override fun getLayout(): Int = R.layout.list_empty_view_subscriptions | ||||
|     override fun bind(viewBinding: ListEmptyViewBinding, position: Int) {} | ||||
|     override fun getSpanSize(spanCount: Int, position: Int): Int = spanCount | ||||
|     override fun initializeViewBinding(view: View) = ListEmptyViewBinding.bind(view) | ||||
| @@ -143,11 +143,9 @@ public final class PlayQueueActivity extends AppCompatActivity | ||||
|                 NavigationHelper.playOnMainPlayer(this, player.getPlayQueue(), true); | ||||
|                 return true; | ||||
|             case R.id.action_switch_popup: | ||||
|                 if (PermissionHelper.isPopupEnabled(this)) { | ||||
|                 if (PermissionHelper.isPopupEnabledElseAsk(this)) { | ||||
|                     this.player.setRecovery(); | ||||
|                     NavigationHelper.playOnPopupPlayer(this, player.getPlayQueue(), true); | ||||
|                 } else { | ||||
|                     PermissionHelper.showPopupEnablementToast(this); | ||||
|                 } | ||||
|                 return true; | ||||
|             case R.id.action_switch_background: | ||||
|   | ||||
| @@ -216,7 +216,6 @@ public final class Player implements PlaybackListener, Listener { | ||||
|     // minimized to background but will resume automatically to the original player type | ||||
|     private boolean isAudioOnly = false; | ||||
|     private boolean isPrepared = false; | ||||
|     private boolean wasPlaying = false; | ||||
|  | ||||
|     /*////////////////////////////////////////////////////////////////////////// | ||||
|     // UIs, listeners and disposables | ||||
| @@ -349,7 +348,7 @@ public final class Player implements PlaybackListener, Listener { | ||||
|         final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString( | ||||
|                 R.string.playback_skip_silence_key), getPlaybackSkipSilence()); | ||||
|  | ||||
|         final boolean samePlayQueue = playQueue != null && playQueue.equals(newQueue); | ||||
|         final boolean samePlayQueue = playQueue != null && playQueue.equalStreamsAndIndex(newQueue); | ||||
|         final int repeatMode = intent.getIntExtra(REPEAT_MODE, getRepeatMode()); | ||||
|         final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true); | ||||
|         final boolean isMuted = intent.getBooleanExtra(IS_MUTED, isMuted()); | ||||
| @@ -918,13 +917,6 @@ public final class Player implements PlaybackListener, Listener { | ||||
|                         error -> Log.e(TAG, "Progress update failure: ", error)); | ||||
|     } | ||||
|  | ||||
|     public void saveWasPlaying() { | ||||
|         this.wasPlaying = getPlayWhenReady(); | ||||
|     } | ||||
|  | ||||
|     public boolean wasPlaying() { | ||||
|         return wasPlaying; | ||||
|     } | ||||
|     //endregion | ||||
|  | ||||
|  | ||||
| @@ -1703,26 +1695,25 @@ public final class Player implements PlaybackListener, Listener { | ||||
|     } | ||||
|  | ||||
|     private void saveStreamProgressState(final long progressMillis) { | ||||
|         //noinspection SimplifyOptionalCallChains | ||||
|         if (!getCurrentStreamInfo().isPresent() | ||||
|                 || !prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) { | ||||
|             return; | ||||
|         } | ||||
|         if (DEBUG) { | ||||
|             Log.d(TAG, "saveStreamProgressState() called with: progressMillis=" + progressMillis | ||||
|                     + ", currentMetadata=[" + getCurrentStreamInfo().get().getName() + "]"); | ||||
|         } | ||||
|         getCurrentStreamInfo().ifPresent(info -> { | ||||
|             if (!prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) { | ||||
|                 return; | ||||
|             } | ||||
|             if (DEBUG) { | ||||
|                 Log.d(TAG, "saveStreamProgressState() called with: progressMillis=" + progressMillis | ||||
|                         + ", currentMetadata=[" + info.getName() + "]"); | ||||
|             } | ||||
|  | ||||
|         databaseUpdateDisposable | ||||
|                 .add(recordManager.saveStreamState(getCurrentStreamInfo().get(), progressMillis) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .doOnError(e -> { | ||||
|                     if (DEBUG) { | ||||
|                         e.printStackTrace(); | ||||
|                     } | ||||
|                 }) | ||||
|                 .onErrorComplete() | ||||
|                 .subscribe()); | ||||
|             databaseUpdateDisposable.add(recordManager.saveStreamState(info, progressMillis) | ||||
|                     .observeOn(AndroidSchedulers.mainThread()) | ||||
|                     .doOnError(e -> { | ||||
|                         if (DEBUG) { | ||||
|                             e.printStackTrace(); | ||||
|                         } | ||||
|                     }) | ||||
|                     .onErrorComplete() | ||||
|                     .subscribe()); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     public void saveStreamProgressState() { | ||||
| @@ -1884,23 +1875,16 @@ public final class Player implements PlaybackListener, Listener { | ||||
|         loadController.disablePreloadingOfCurrentTrack(); | ||||
|     } | ||||
|  | ||||
|     @Nullable | ||||
|     public VideoStream getSelectedVideoStream() { | ||||
|         @Nullable final MediaItemTag.Quality quality = Optional.ofNullable(currentMetadata) | ||||
|     public Optional<VideoStream> getSelectedVideoStream() { | ||||
|         return Optional.ofNullable(currentMetadata) | ||||
|                 .flatMap(MediaItemTag::getMaybeQuality) | ||||
|                 .orElse(null); | ||||
|         if (quality == null) { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         final List<VideoStream> availableStreams = quality.getSortedVideoStreams(); | ||||
|         final int selectedStreamIndex = quality.getSelectedVideoStreamIndex(); | ||||
|  | ||||
|         if (selectedStreamIndex >= 0 && availableStreams.size() > selectedStreamIndex) { | ||||
|             return availableStreams.get(selectedStreamIndex); | ||||
|         } else { | ||||
|             return null; | ||||
|         } | ||||
|                 .filter(quality -> { | ||||
|                     final int selectedStreamIndex = quality.getSelectedVideoStreamIndex(); | ||||
|                     return selectedStreamIndex >= 0 | ||||
|                             && selectedStreamIndex < quality.getSortedVideoStreams().size(); | ||||
|                 }) | ||||
|                 .map(quality -> quality.getSortedVideoStreams() | ||||
|                         .get(quality.getSelectedVideoStreamIndex())); | ||||
|     } | ||||
|     //endregion | ||||
|  | ||||
| @@ -2044,40 +2028,36 @@ public final class Player implements PlaybackListener, Listener { | ||||
|         // in livestreams) so we will be not able to execute the block below. | ||||
|         // Reload the play queue manager in this case, which is the behavior when we don't know the | ||||
|         // index of the video renderer or playQueueManagerReloadingNeeded returns true. | ||||
|         final Optional<StreamInfo> optCurrentStreamInfo = getCurrentStreamInfo(); | ||||
|         if (!optCurrentStreamInfo.isPresent()) { | ||||
|             reloadPlayQueueManager(); | ||||
|             setRecovery(); | ||||
|             return; | ||||
|         } | ||||
|         getCurrentStreamInfo().ifPresentOrElse(info -> { | ||||
|             // In the case we don't know the source type, fallback to the one with video with audio | ||||
|             // or audio-only source. | ||||
|             final SourceType sourceType = videoResolver.getStreamSourceType() | ||||
|                     .orElse(SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY); | ||||
|  | ||||
|         final StreamInfo info = optCurrentStreamInfo.get(); | ||||
|             if (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) { | ||||
|                 reloadPlayQueueManager(); | ||||
|             } else { | ||||
|                 if (StreamTypeUtil.isAudio(info.getStreamType())) { | ||||
|                     // Nothing to do more than setting the recovery position | ||||
|                     setRecovery(); | ||||
|                     return; | ||||
|                 } | ||||
|  | ||||
|         // In the case we don't know the source type, fallback to the one with video with audio or | ||||
|         // audio-only source. | ||||
|         final SourceType sourceType = videoResolver.getStreamSourceType().orElse( | ||||
|                 SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY); | ||||
|                 final var parametersBuilder = trackSelector.buildUponParameters(); | ||||
|  | ||||
|         if (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) { | ||||
|             reloadPlayQueueManager(); | ||||
|         } else { | ||||
|             if (StreamTypeUtil.isAudio(info.getStreamType())) { | ||||
|                 // Nothing to do more than setting the recovery position | ||||
|                 setRecovery(); | ||||
|                 return; | ||||
|                 // Enable/disable the video track and the ability to select subtitles | ||||
|                 parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !videoEnabled); | ||||
|                 parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, !videoEnabled); | ||||
|  | ||||
|                 trackSelector.setParameters(parametersBuilder); | ||||
|             } | ||||
|  | ||||
|             final DefaultTrackSelector.Parameters.Builder parametersBuilder = | ||||
|                     trackSelector.buildUponParameters(); | ||||
|  | ||||
|             // Enable/disable the video track and the ability to select subtitles | ||||
|             parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !videoEnabled); | ||||
|             parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, !videoEnabled); | ||||
|  | ||||
|             trackSelector.setParameters(parametersBuilder); | ||||
|         } | ||||
|  | ||||
|         setRecovery(); | ||||
|             setRecovery(); | ||||
|         }, () -> { | ||||
|             // This is executed when the current stream info is not available. | ||||
|             reloadPlayQueueManager(); | ||||
|             setRecovery(); | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -86,8 +86,6 @@ public final class PlayerService extends Service { | ||||
|         } | ||||
|  | ||||
|         if (!player.exoPlayerIsNull()) { | ||||
|             player.saveWasPlaying(); | ||||
|  | ||||
|             // Releases wifi & cpu, disables keepScreenOn, etc. | ||||
|             // We can't just pause the player here because it will make transition | ||||
|             // from one stream to a new stream not smooth | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import android.util.Log | ||||
| import android.view.GestureDetector | ||||
| import android.view.MotionEvent | ||||
| import android.view.View | ||||
| import androidx.core.os.postDelayed | ||||
| import org.schabi.newpipe.databinding.PlayerBinding | ||||
| import org.schabi.newpipe.player.Player | ||||
| import org.schabi.newpipe.player.ui.VideoPlayerUi | ||||
| @@ -132,13 +133,6 @@ abstract class BasePlayerGestureListener( | ||||
|  | ||||
|     private var doubleTapDelay = DOUBLE_TAP_DELAY | ||||
|     private val doubleTapHandler: Handler = Handler(Looper.getMainLooper()) | ||||
|     private val doubleTapRunnable = Runnable { | ||||
|         if (DEBUG) | ||||
|             Log.d(TAG, "doubleTapRunnable called") | ||||
|  | ||||
|         isDoubleTapping = false | ||||
|         doubleTapControls?.onDoubleTapFinished() | ||||
|     } | ||||
|  | ||||
|     private fun startMultiDoubleTap(e: MotionEvent) { | ||||
|         if (!isDoubleTapping) { | ||||
| @@ -155,8 +149,15 @@ abstract class BasePlayerGestureListener( | ||||
|             Log.d(TAG, "keepInDoubleTapMode called") | ||||
|  | ||||
|         isDoubleTapping = true | ||||
|         doubleTapHandler.removeCallbacks(doubleTapRunnable) | ||||
|         doubleTapHandler.postDelayed(doubleTapRunnable, doubleTapDelay) | ||||
|         doubleTapHandler.removeCallbacksAndMessages(DOUBLE_TAP) | ||||
|         doubleTapHandler.postDelayed(DOUBLE_TAP_DELAY, DOUBLE_TAP) { | ||||
|             if (DEBUG) { | ||||
|                 Log.d(TAG, "doubleTapRunnable called") | ||||
|             } | ||||
|  | ||||
|             isDoubleTapping = false | ||||
|             doubleTapControls?.onDoubleTapFinished() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun endMultiDoubleTap() { | ||||
| @@ -164,7 +165,7 @@ abstract class BasePlayerGestureListener( | ||||
|             Log.d(TAG, "endMultiDoubleTap called") | ||||
|  | ||||
|         isDoubleTapping = false | ||||
|         doubleTapHandler.removeCallbacks(doubleTapRunnable) | ||||
|         doubleTapHandler.removeCallbacksAndMessages(DOUBLE_TAP) | ||||
|         doubleTapControls?.onDoubleTapFinished() | ||||
|     } | ||||
|  | ||||
| @@ -181,6 +182,7 @@ abstract class BasePlayerGestureListener( | ||||
|         private const val TAG = "BasePlayerGestListener" | ||||
|         private val DEBUG = Player.DEBUG | ||||
|  | ||||
|         private const val DOUBLE_TAP = "doubleTap" | ||||
|         private const val DOUBLE_TAP_DELAY = 550L | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -160,15 +160,15 @@ class PopupPlayerGestureListener( | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onLongPress(e: MotionEvent?) { | ||||
|     override fun onLongPress(e: MotionEvent) { | ||||
|         playerUi.updateScreenSize() | ||||
|         playerUi.checkPopupPositionBounds() | ||||
|         playerUi.changePopupSize(playerUi.screenWidth) | ||||
|     } | ||||
|  | ||||
|     override fun onFling( | ||||
|         e1: MotionEvent?, | ||||
|         e2: MotionEvent?, | ||||
|         e1: MotionEvent, | ||||
|         e2: MotionEvent, | ||||
|         velocityX: Float, | ||||
|         velocityY: Float | ||||
|     ): Boolean { | ||||
|   | ||||
| @@ -92,6 +92,13 @@ public final class PlayerHolder { | ||||
|         return player.getPlayQueue().size(); | ||||
|     } | ||||
|  | ||||
|     public int getQueuePosition() { | ||||
|         if (player == null || player.getPlayQueue() == null) { | ||||
|             return 0; | ||||
|         } | ||||
|         return player.getPlayQueue().getIndex(); | ||||
|     } | ||||
|  | ||||
|     public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) { | ||||
|         listener = newListener; | ||||
|  | ||||
|   | ||||
| @@ -61,12 +61,11 @@ public interface MediaItemTag { | ||||
|  | ||||
|     @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); | ||||
|         return Optional.ofNullable(mediaItem) | ||||
|                 .map(item -> item.localConfiguration) | ||||
|                 .map(localConfiguration -> localConfiguration.tag) | ||||
|                 .filter(MediaItemTag.class::isInstance) | ||||
|                 .map(MediaItemTag.class::cast); | ||||
|     } | ||||
|  | ||||
|     @NonNull | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| package org.schabi.newpipe.player.notification; | ||||
|  | ||||
| import android.annotation.SuppressLint; | ||||
| import android.app.PendingIntent; | ||||
| import android.content.Intent; | ||||
| import android.content.pm.ServiceInfo; | ||||
| import android.graphics.Bitmap; | ||||
| @@ -22,6 +21,7 @@ import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.player.Player; | ||||
| import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; | ||||
| import org.schabi.newpipe.util.NavigationHelper; | ||||
| import org.schabi.newpipe.util.PendingIntentCompat; | ||||
|  | ||||
| import java.util.List; | ||||
| import java.util.Objects; | ||||
| @@ -133,8 +133,8 @@ public final class NotificationUtil { | ||||
|                         R.color.dark_background_color)) | ||||
|                 .setColorized(player.getPrefs().getBoolean( | ||||
|                         player.getContext().getString(R.string.notification_colorize_key), true)) | ||||
|                 .setDeleteIntent(PendingIntent.getBroadcast(player.getContext(), NOTIFICATION_ID, | ||||
|                         new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT)); | ||||
|                 .setDeleteIntent(PendingIntentCompat.getBroadcast(player.getContext(), | ||||
|                         NOTIFICATION_ID, new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT)); | ||||
|  | ||||
|         // set the initial value for the video thumbnail, updatable with updateNotificationThumbnail | ||||
|         setLargeIcon(builder); | ||||
| @@ -151,7 +151,7 @@ public final class NotificationUtil { | ||||
|         } | ||||
|  | ||||
|         // also update content intent, in case the user switched players | ||||
|         notificationBuilder.setContentIntent(PendingIntent.getActivity(player.getContext(), | ||||
|         notificationBuilder.setContentIntent(PendingIntentCompat.getActivity(player.getContext(), | ||||
|                 NOTIFICATION_ID, getIntentForNotification(), FLAG_UPDATE_CURRENT)); | ||||
|         notificationBuilder.setContentTitle(player.getVideoTitle()); | ||||
|         notificationBuilder.setContentText(player.getUploaderName()); | ||||
| @@ -334,7 +334,7 @@ public final class NotificationUtil { | ||||
|                                                 @StringRes final int title, | ||||
|                                                 final String intentAction) { | ||||
|         return new NotificationCompat.Action(drawable, player.getContext().getString(title), | ||||
|                 PendingIntent.getBroadcast(player.getContext(), NOTIFICATION_ID, | ||||
|                 PendingIntentCompat.getBroadcast(player.getContext(), NOTIFICATION_ID, | ||||
|                         new Intent(intentAction), FLAG_UPDATE_CURRENT)); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -7,8 +7,6 @@ import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.collection.ArraySet; | ||||
|  | ||||
| import com.google.android.exoplayer2.source.MediaSource; | ||||
|  | ||||
| import org.reactivestreams.Subscriber; | ||||
| import org.reactivestreams.Subscription; | ||||
| import org.schabi.newpipe.extractor.exceptions.ExtractionException; | ||||
| @@ -23,10 +21,10 @@ import org.schabi.newpipe.player.playqueue.events.MoveEvent; | ||||
| import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent; | ||||
| import org.schabi.newpipe.player.playqueue.events.RemoveEvent; | ||||
| import org.schabi.newpipe.player.playqueue.events.ReorderEvent; | ||||
| import org.schabi.newpipe.util.ServiceHelper; | ||||
|  | ||||
| import java.util.Collection; | ||||
| import java.util.Collections; | ||||
| import java.util.Optional; | ||||
| import java.util.Set; | ||||
| import java.util.concurrent.TimeUnit; | ||||
| import java.util.concurrent.atomic.AtomicBoolean; | ||||
| @@ -43,6 +41,7 @@ import io.reactivex.rxjava3.subjects.PublishSubject; | ||||
| import static org.schabi.newpipe.player.mediasource.FailedMediaSource.MediaSourceResolutionException; | ||||
| import static org.schabi.newpipe.player.mediasource.FailedMediaSource.StreamInfoLoadException; | ||||
| import static org.schabi.newpipe.player.playqueue.PlayQueue.DEBUG; | ||||
| import static org.schabi.newpipe.util.ServiceHelper.getCacheExpirationMillis; | ||||
|  | ||||
| public class MediaSourceManager { | ||||
|     @NonNull | ||||
| @@ -421,31 +420,39 @@ 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 || !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 (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, 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)); | ||||
|         }); | ||||
|         return stream.getStream() | ||||
|                 .map(streamInfo -> Optional | ||||
|                         .ofNullable(playbackListener.sourceOf(stream, streamInfo)) | ||||
|                         .<ManagedMediaSource>flatMap(source -> | ||||
|                                 MediaItemTag.from(source.getMediaItem()) | ||||
|                                         .map(tag -> { | ||||
|                                             final int serviceId = streamInfo.getServiceId(); | ||||
|                                             final long expiration = System.currentTimeMillis() | ||||
|                                                     + getCacheExpirationMillis(serviceId); | ||||
|                                             return new LoadedMediaSource(source, tag, stream, | ||||
|                                                     expiration); | ||||
|                                         }) | ||||
|                         ) | ||||
|                         .orElseGet(() -> { | ||||
|                             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 FailedMediaSource.of(stream, | ||||
|                                     new MediaSourceResolutionException(message)); | ||||
|                         }) | ||||
|                 ) | ||||
|                 .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. | ||||
|                     final long allowRetryIn = TimeUnit.MILLISECONDS.convert(3, | ||||
|                             TimeUnit.SECONDS); | ||||
|                     return FailedMediaSource.of(stream, new Exception(throwable), allowRetryIn); | ||||
|                 }); | ||||
|     } | ||||
|  | ||||
|     private void onMediaSourceReceived(@NonNull final PlayQueueItem item, | ||||
|   | ||||
| @@ -518,12 +518,10 @@ public abstract class PlayQueue implements Serializable { | ||||
|      * This method also gives a chance to track history of items in a queue in | ||||
|      * VideoDetailFragment without duplicating items from two identical queues | ||||
|      */ | ||||
|     @Override | ||||
|     public boolean equals(@Nullable final Object obj) { | ||||
|         if (!(obj instanceof PlayQueue)) { | ||||
|     public boolean equalStreams(@Nullable final PlayQueue other) { | ||||
|         if (other == null) { | ||||
|             return false; | ||||
|         } | ||||
|         final PlayQueue other = (PlayQueue) obj; | ||||
|         if (size() != other.size()) { | ||||
|             return false; | ||||
|         } | ||||
| @@ -539,9 +537,11 @@ public abstract class PlayQueue implements Serializable { | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public int hashCode() { | ||||
|         return streams.hashCode(); | ||||
|     public boolean equalStreamsAndIndex(@Nullable final PlayQueue other) { | ||||
|         if (equalStreams(other)) { | ||||
|             return other.getIndex() == getIndex(); | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     public boolean isDisposed() { | ||||
|   | ||||
| @@ -11,7 +11,9 @@ import androidx.annotation.Nullable; | ||||
| import com.google.android.exoplayer2.source.MediaSource; | ||||
|  | ||||
| import org.schabi.newpipe.extractor.stream.AudioStream; | ||||
| import org.schabi.newpipe.extractor.stream.Stream; | ||||
| import org.schabi.newpipe.extractor.stream.StreamInfo; | ||||
| import org.schabi.newpipe.extractor.stream.VideoStream; | ||||
| import org.schabi.newpipe.player.helper.PlayerDataSource; | ||||
| import org.schabi.newpipe.player.mediaitem.MediaItemTag; | ||||
| import org.schabi.newpipe.player.mediaitem.StreamInfoTag; | ||||
| @@ -41,22 +43,50 @@ public class AudioPlaybackResolver implements PlaybackResolver { | ||||
|             return liveSource; | ||||
|         } | ||||
|  | ||||
|         final List<AudioStream> audioStreams = getNonTorrentStreams(info.getAudioStreams()); | ||||
|  | ||||
|         final int index = ListHelper.getDefaultAudioFormat(context, audioStreams); | ||||
|         if (index < 0 || index >= info.getAudioStreams().size()) { | ||||
|         final Stream stream = getAudioSource(info); | ||||
|         if (stream == null) { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         final AudioStream audio = info.getAudioStreams().get(index); | ||||
|         final MediaItemTag tag = StreamInfoTag.of(info); | ||||
|  | ||||
|         try { | ||||
|             return PlaybackResolver.buildMediaSource( | ||||
|                     dataSource, audio, info, PlaybackResolver.cacheKeyOf(info, audio), tag); | ||||
|                     dataSource, stream, info, PlaybackResolver.cacheKeyOf(info, stream), tag); | ||||
|         } catch (final ResolverException e) { | ||||
|             Log.e(TAG, "Unable to create audio source", e); | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get a stream to be played as audio. If a service has no separate {@link AudioStream}s we | ||||
|      * use a video stream as audio source to support audio background playback. | ||||
|      * | ||||
|      * @param info of the stream | ||||
|      * @return the audio source to use or null if none could be found | ||||
|      */ | ||||
|     @Nullable | ||||
|     private Stream getAudioSource(@NonNull final StreamInfo info) { | ||||
|         final List<AudioStream> audioStreams = getNonTorrentStreams(info.getAudioStreams()); | ||||
|         if (!audioStreams.isEmpty()) { | ||||
|             final int index = ListHelper.getDefaultAudioFormat(context, audioStreams); | ||||
|             return getStreamForIndex(index, audioStreams); | ||||
|         } else { | ||||
|             final List<VideoStream> videoStreams = getNonTorrentStreams(info.getVideoStreams()); | ||||
|             if (!videoStreams.isEmpty()) { | ||||
|                 final int index = ListHelper.getDefaultResolutionIndex(context, videoStreams); | ||||
|                 return getStreamForIndex(index, videoStreams); | ||||
|             } | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     @Nullable | ||||
|     Stream getStreamForIndex(final int index, @NonNull final List<? extends Stream> streams) { | ||||
|         if (index >= 0 && index < streams.size()) { | ||||
|             return streams.get(index); | ||||
|         } | ||||
|         return null; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -158,6 +158,26 @@ public interface PlaybackResolver extends Resolver<StreamInfo, MediaSource> { | ||||
|  | ||||
|         return cacheKey.toString(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Use common base type {@link Stream} to handle {@link AudioStream} or {@link VideoStream} | ||||
|      * transparently. For more info see {@link #cacheKeyOf(StreamInfo, AudioStream)} or | ||||
|      * {@link #cacheKeyOf(StreamInfo, VideoStream)}. | ||||
|      * | ||||
|      * @param info   the {@link StreamInfo stream info}, to distinguish between streams with | ||||
|      *               the same features but coming from different stream infos | ||||
|      * @param stream the {@link Stream} ({@link AudioStream} or {@link VideoStream}) | ||||
|      *               for which the cache key should be created | ||||
|      * @return a key to be used to store the cache of the provided {@link Stream} | ||||
|      */ | ||||
|     static String cacheKeyOf(final StreamInfo info, final Stream stream) { | ||||
|         if (stream instanceof AudioStream) { | ||||
|             return cacheKeyOf(info, (AudioStream) stream); | ||||
|         } else if (stream instanceof VideoStream) { | ||||
|             return cacheKeyOf(info, (VideoStream) stream); | ||||
|         } | ||||
|         throw new RuntimeException("no audio or video stream. That should never happen"); | ||||
|     } | ||||
|     //endregion | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -8,6 +8,8 @@ import android.widget.ImageView; | ||||
|  | ||||
| import androidx.annotation.IntDef; | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.core.graphics.BitmapCompat; | ||||
| import androidx.core.math.MathUtils; | ||||
| import androidx.preference.PreferenceManager; | ||||
|  | ||||
| @@ -15,7 +17,6 @@ import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.util.DeviceUtils; | ||||
|  | ||||
| import java.lang.annotation.Retention; | ||||
| import java.util.Optional; | ||||
| import java.util.function.IntSupplier; | ||||
|  | ||||
| import static java.lang.annotation.RetentionPolicy.SOURCE; | ||||
| @@ -65,21 +66,19 @@ public final class SeekbarPreviewThumbnailHelper { | ||||
|  | ||||
|     public static void tryResizeAndSetSeekbarPreviewThumbnail( | ||||
|             @NonNull final Context context, | ||||
|             @NonNull final Optional<Bitmap> optPreviewThumbnail, | ||||
|             @Nullable final Bitmap previewThumbnail, | ||||
|             @NonNull final ImageView currentSeekbarPreviewThumbnail, | ||||
|             @NonNull final IntSupplier baseViewWidthSupplier) { | ||||
|  | ||||
|         if (!optPreviewThumbnail.isPresent()) { | ||||
|         if (previewThumbnail == null) { | ||||
|             currentSeekbarPreviewThumbnail.setVisibility(View.GONE); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         currentSeekbarPreviewThumbnail.setVisibility(View.VISIBLE); | ||||
|         final Bitmap srcBitmap = optPreviewThumbnail.get(); | ||||
|  | ||||
|         // Resize original bitmap | ||||
|         try { | ||||
|             final int srcWidth = srcBitmap.getWidth() > 0 ? srcBitmap.getWidth() : 1; | ||||
|             final int srcWidth = previewThumbnail.getWidth() > 0 ? previewThumbnail.getWidth() : 1; | ||||
|             final int newWidth = MathUtils.clamp( | ||||
|                     // Use 1/4 of the width for the preview | ||||
|                     Math.round(baseViewWidthSupplier.getAsInt() / 4f), | ||||
| @@ -89,15 +88,15 @@ public final class SeekbarPreviewThumbnailHelper { | ||||
|                     Math.round(srcWidth * 2.5f)); | ||||
|  | ||||
|             final float scaleFactor = (float) newWidth / srcWidth; | ||||
|             final int newHeight = (int) (srcBitmap.getHeight() * scaleFactor); | ||||
|             final int newHeight = (int) (previewThumbnail.getHeight() * scaleFactor); | ||||
|  | ||||
|             currentSeekbarPreviewThumbnail.setImageBitmap( | ||||
|                     Bitmap.createScaledBitmap(srcBitmap, newWidth, newHeight, true)); | ||||
|             currentSeekbarPreviewThumbnail.setImageBitmap(BitmapCompat | ||||
|                     .createScaledBitmap(previewThumbnail, newWidth, newHeight, null, true)); | ||||
|         } catch (final Exception ex) { | ||||
|             Log.e(TAG, "Failed to resize and set seekbar preview thumbnail", ex); | ||||
|             currentSeekbarPreviewThumbnail.setVisibility(View.GONE); | ||||
|         } finally { | ||||
|             srcBitmap.recycle(); | ||||
|             previewThumbnail.recycle(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| package org.schabi.newpipe.player.seekbarpreview; | ||||
|  | ||||
| import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType; | ||||
| import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.getSeekbarPreviewThumbnailType; | ||||
|  | ||||
| import android.content.Context; | ||||
| import android.graphics.Bitmap; | ||||
| @@ -8,6 +9,7 @@ import android.util.Log; | ||||
|  | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.collection.SparseArrayCompat; | ||||
|  | ||||
| import com.google.common.base.Stopwatch; | ||||
|  | ||||
| @@ -15,12 +17,9 @@ import org.schabi.newpipe.extractor.stream.Frameset; | ||||
| import org.schabi.newpipe.util.PicassoHelper; | ||||
|  | ||||
| import java.util.Comparator; | ||||
| import java.util.HashMap; | ||||
| import java.util.List; | ||||
| import java.util.Map; | ||||
| import java.util.Optional; | ||||
| import java.util.UUID; | ||||
| import java.util.concurrent.ConcurrentHashMap; | ||||
| import java.util.concurrent.ExecutorService; | ||||
| import java.util.concurrent.Executors; | ||||
| import java.util.function.Supplier; | ||||
| @@ -34,18 +33,15 @@ public class SeekbarPreviewThumbnailHolder { | ||||
|  | ||||
|     // Key = Position of the picture in milliseconds | ||||
|     // Supplier = Supplies the bitmap for that position | ||||
|     private final Map<Integer, Supplier<Bitmap>> seekbarPreviewData = new ConcurrentHashMap<>(); | ||||
|     private final SparseArrayCompat<Supplier<Bitmap>> seekbarPreviewData = | ||||
|             new SparseArrayCompat<>(); | ||||
|  | ||||
|     // This ensures that if the reset is still undergoing | ||||
|     // and another reset starts, only the last reset is processed | ||||
|     private UUID currentUpdateRequestIdentifier = UUID.randomUUID(); | ||||
|  | ||||
|     public synchronized void resetFrom( | ||||
|             @NonNull final Context context, | ||||
|             final List<Frameset> framesets) { | ||||
|  | ||||
|         final int seekbarPreviewType = | ||||
|                 SeekbarPreviewThumbnailHelper.getSeekbarPreviewThumbnailType(context); | ||||
|     public void resetFrom(@NonNull final Context context, final List<Frameset> framesets) { | ||||
|         final int seekbarPreviewType = getSeekbarPreviewThumbnailType(context); | ||||
|  | ||||
|         final UUID updateRequestIdentifier = UUID.randomUUID(); | ||||
|         this.currentUpdateRequestIdentifier = updateRequestIdentifier; | ||||
| @@ -63,13 +59,12 @@ public class SeekbarPreviewThumbnailHolder { | ||||
|         executorService.shutdown(); | ||||
|     } | ||||
|  | ||||
|     private void resetFromAsync( | ||||
|             final int seekbarPreviewType, | ||||
|             final List<Frameset> framesets, | ||||
|             final UUID updateRequestIdentifier) { | ||||
|  | ||||
|     private void resetFromAsync(final int seekbarPreviewType, final List<Frameset> framesets, | ||||
|                                 final UUID updateRequestIdentifier) { | ||||
|         Log.d(TAG, "Clearing seekbarPreviewData"); | ||||
|         seekbarPreviewData.clear(); | ||||
|         synchronized (seekbarPreviewData) { | ||||
|             seekbarPreviewData.clear(); | ||||
|         } | ||||
|  | ||||
|         if (seekbarPreviewType == SeekbarPreviewThumbnailType.NONE) { | ||||
|             Log.d(TAG, "Not processing seekbarPreviewData due to settings"); | ||||
| @@ -94,10 +89,8 @@ public class SeekbarPreviewThumbnailHolder { | ||||
|         generateDataFrom(frameset, updateRequestIdentifier); | ||||
|     } | ||||
|  | ||||
|     private Frameset getFrameSetForType( | ||||
|             final List<Frameset> framesets, | ||||
|             final int seekbarPreviewType) { | ||||
|  | ||||
|     private Frameset getFrameSetForType(final List<Frameset> framesets, | ||||
|                                         final int seekbarPreviewType) { | ||||
|         if (seekbarPreviewType == SeekbarPreviewThumbnailType.HIGH_QUALITY) { | ||||
|             Log.d(TAG, "Strategy for seekbarPreviewData: high quality"); | ||||
|             return framesets.stream() | ||||
| @@ -111,17 +104,14 @@ public class SeekbarPreviewThumbnailHolder { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void generateDataFrom( | ||||
|             final Frameset frameset, | ||||
|             final UUID updateRequestIdentifier) { | ||||
|  | ||||
|     private void generateDataFrom(final Frameset frameset, final UUID updateRequestIdentifier) { | ||||
|         Log.d(TAG, "Starting generation of seekbarPreviewData"); | ||||
|         final Stopwatch sw = Log.isLoggable(TAG, Log.DEBUG) ? Stopwatch.createStarted() : null; | ||||
|  | ||||
|         int currentPosMs = 0; | ||||
|         int pos = 1; | ||||
|  | ||||
|         final int frameCountPerUrl = frameset.getFramesPerPageX() * frameset.getFramesPerPageY(); | ||||
|         final int urlFrameCount = frameset.getFramesPerPageX() * frameset.getFramesPerPageY(); | ||||
|  | ||||
|         // Process each url in the frameset | ||||
|         for (final String url : frameset.getUrls()) { | ||||
| @@ -130,11 +120,11 @@ public class SeekbarPreviewThumbnailHolder { | ||||
|  | ||||
|             // The data is not added directly to "seekbarPreviewData" due to | ||||
|             // concurrency and checks for "updateRequestIdentifier" | ||||
|             final Map<Integer, Supplier<Bitmap>> generatedDataForUrl = new HashMap<>(); | ||||
|             final var generatedDataForUrl = new SparseArrayCompat<Supplier<Bitmap>>(urlFrameCount); | ||||
|  | ||||
|             // The bitmap consists of several images, which we process here | ||||
|             // foreach frame in the returned bitmap | ||||
|             for (int i = 0; i < frameCountPerUrl; i++) { | ||||
|             for (int i = 0; i < urlFrameCount; i++) { | ||||
|                 // Frames outside the video length are skipped | ||||
|                 if (pos > frameset.getTotalCount()) { | ||||
|                     break; | ||||
| @@ -161,7 +151,9 @@ public class SeekbarPreviewThumbnailHolder { | ||||
|             // Check if we are still the latest request | ||||
|             // If not abort method execution | ||||
|             if (isRequestIdentifierCurrent(updateRequestIdentifier)) { | ||||
|                 seekbarPreviewData.putAll(generatedDataForUrl); | ||||
|                 synchronized (seekbarPreviewData) { | ||||
|                     seekbarPreviewData.putAll(generatedDataForUrl); | ||||
|                 } | ||||
|             } else { | ||||
|                 Log.d(TAG, "Aborted of generation of seekbarPreviewData"); | ||||
|                 break; | ||||
| @@ -169,7 +161,7 @@ public class SeekbarPreviewThumbnailHolder { | ||||
|         } | ||||
|  | ||||
|         if (sw != null) { | ||||
|             Log.d(TAG, "Generation of seekbarPreviewData took " + sw.stop().toString()); | ||||
|             Log.d(TAG, "Generation of seekbarPreviewData took " + sw.stop()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -189,17 +181,14 @@ public class SeekbarPreviewThumbnailHolder { | ||||
|             final Bitmap bitmap = PicassoHelper.loadSeekbarThumbnailPreview(url).get(); | ||||
|  | ||||
|             if (sw != null) { | ||||
|                 Log.d(TAG, | ||||
|                         "Download of bitmap for seekbarPreview from '" + url | ||||
|                                 + "' took " + sw.stop().toString()); | ||||
|                 Log.d(TAG, "Download of bitmap for seekbarPreview from '" + url + "' took " | ||||
|                         + sw.stop()); | ||||
|             } | ||||
|  | ||||
|             return bitmap; | ||||
|         } catch (final Exception ex) { | ||||
|             Log.w(TAG, | ||||
|                     "Failed to get bitmap for seekbarPreview from url='" + url | ||||
|                             + "' in time", | ||||
|                     ex); | ||||
|             Log.w(TAG, "Failed to get bitmap for seekbarPreview from url='" + url | ||||
|                     + "' in time", ex); | ||||
|             return null; | ||||
|         } | ||||
|     } | ||||
| @@ -208,32 +197,20 @@ public class SeekbarPreviewThumbnailHolder { | ||||
|         return this.currentUpdateRequestIdentifier.equals(requestIdentifier); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     public Optional<Bitmap> getBitmapAt(final int positionInMs) { | ||||
|         // Check if the BitmapData is empty | ||||
|         if (seekbarPreviewData.isEmpty()) { | ||||
|             return Optional.empty(); | ||||
|         // Get the frame supplier closest to the requested position | ||||
|         Supplier<Bitmap> closestFrame = () -> null; | ||||
|         synchronized (seekbarPreviewData) { | ||||
|             int min = Integer.MAX_VALUE; | ||||
|             for (int i = 0; i < seekbarPreviewData.size(); i++) { | ||||
|                 final int pos = Math.abs(seekbarPreviewData.keyAt(i) - positionInMs); | ||||
|                 if (pos < min) { | ||||
|                     closestFrame = seekbarPreviewData.valueAt(i); | ||||
|                     min = pos; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Get the closest frame to the requested position | ||||
|         final int closestIndexPosition = | ||||
|                 seekbarPreviewData.keySet().stream() | ||||
|                         .min(Comparator.comparingInt(i -> Math.abs(i - positionInMs))) | ||||
|                         .orElse(-1); | ||||
|  | ||||
|         // this should never happen, because | ||||
|         // it indicates that "seekbarPreviewData" is empty which was already checked | ||||
|         if (closestIndexPosition == -1) { | ||||
|             return Optional.empty(); | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             // Get the bitmap for the position (executes the supplier) | ||||
|             return Optional.ofNullable(seekbarPreviewData.get(closestIndexPosition).get()); | ||||
|         } catch (final Exception ex) { | ||||
|             // If there is an error, log it and return Optional.empty | ||||
|             Log.w(TAG, "Unable to get seekbar preview", ex); | ||||
|             return Optional.empty(); | ||||
|         } | ||||
|         return Optional.ofNullable(closestFrame.get()); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -32,7 +32,6 @@ import android.view.KeyEvent; | ||||
| import android.view.View; | ||||
| import android.view.ViewGroup; | ||||
| import android.view.ViewParent; | ||||
| import android.view.WindowManager; | ||||
| import android.widget.FrameLayout; | ||||
| import android.widget.LinearLayout; | ||||
|  | ||||
| @@ -40,6 +39,8 @@ import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.appcompat.app.AppCompatActivity; | ||||
| import androidx.appcompat.content.res.AppCompatResources; | ||||
| import androidx.core.view.WindowCompat; | ||||
| import androidx.core.view.WindowInsetsCompat; | ||||
| import androidx.fragment.app.FragmentActivity; | ||||
| import androidx.recyclerview.widget.ItemTouchHelper; | ||||
| import androidx.recyclerview.widget.RecyclerView; | ||||
| @@ -74,6 +75,7 @@ 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 java.util.List; | ||||
| import java.util.Objects; | ||||
| import java.util.Optional; | ||||
| @@ -154,6 +156,16 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh | ||||
|     protected void initListeners() { | ||||
|         super.initListeners(); | ||||
|  | ||||
|         binding.screenRotationButton.setOnClickListener(makeOnClickListener(() -> { | ||||
|             // Only if it's not a vertical video or vertical video but in landscape with locked | ||||
|             // orientation a screen orientation can be changed automatically | ||||
|             if (!isVerticalVideo || (isLandscape() && globalScreenOrientationLocked(context))) { | ||||
|                 player.getFragmentListener() | ||||
|                         .ifPresent(PlayerServiceEventListener::onScreenRotationButtonClicked); | ||||
|             } else { | ||||
|                 toggleFullscreen(); | ||||
|             } | ||||
|         })); | ||||
|         binding.queueButton.setOnClickListener(v -> onQueueClicked()); | ||||
|         binding.segmentsButton.setOnClickListener(v -> onSegmentsClicked()); | ||||
|  | ||||
| @@ -173,6 +185,14 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh | ||||
|                 settingsContentObserver); | ||||
|  | ||||
|         binding.getRoot().addOnLayoutChangeListener(this); | ||||
|  | ||||
|         binding.moreOptionsButton.setOnLongClickListener(v -> { | ||||
|             player.getFragmentListener() | ||||
|                     .ifPresent(PlayerServiceEventListener::onMoreOptionsLongClicked); | ||||
|             hideControls(0, 0); | ||||
|             hideSystemUIIfNeeded(); | ||||
|             return true; | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
| @@ -433,11 +453,9 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh | ||||
|             getParentActivity().map(Activity::getWindow).ifPresent(window -> { | ||||
|                 window.setStatusBarColor(Color.TRANSPARENT); | ||||
|                 window.setNavigationBarColor(Color.TRANSPARENT); | ||||
|                 final int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | ||||
|                         | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | ||||
|                         | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION; | ||||
|                 window.getDecorView().setSystemUiVisibility(visibility); | ||||
|                 window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); | ||||
|                 WindowCompat.setDecorFitsSystemWindows(window, false); | ||||
|                 WindowCompat.getInsetsController(window, window.getDecorView()) | ||||
|                         .show(WindowInsetsCompat.Type.systemBars()); | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| @@ -728,15 +746,10 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh | ||||
|     } | ||||
|  | ||||
|     private int getNearestStreamSegmentPosition(final long playbackPosition) { | ||||
|         //noinspection SimplifyOptionalCallChains | ||||
|         if (!player.getCurrentStreamInfo().isPresent()) { | ||||
|             return 0; | ||||
|         } | ||||
|  | ||||
|         int nearestPosition = 0; | ||||
|         final List<StreamSegment> segments = player.getCurrentStreamInfo() | ||||
|                 .get() | ||||
|                 .getStreamSegments(); | ||||
|                 .map(StreamInfo::getStreamSegments) | ||||
|                 .orElse(Collections.emptyList()); | ||||
|  | ||||
|         for (int i = 0; i < segments.size(); i++) { | ||||
|             if (segments.get(i).getStartTimeSeconds() * 1000L > playbackPosition) { | ||||
| @@ -846,45 +859,13 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh | ||||
|     //////////////////////////////////////////////////////////////////////////*/ | ||||
|     //region Click listeners | ||||
|  | ||||
|     @Override | ||||
|     public void onClick(final View v) { | ||||
|         if (v.getId() == binding.screenRotationButton.getId()) { | ||||
|             // Only if it's not a vertical video or vertical video but in landscape with locked | ||||
|             // orientation a screen orientation can be changed automatically | ||||
|             if (!isVerticalVideo || (isLandscape() && globalScreenOrientationLocked(context))) { | ||||
|                 player.getFragmentListener().ifPresent( | ||||
|                         PlayerServiceEventListener::onScreenRotationButtonClicked); | ||||
|             } else { | ||||
|                 toggleFullscreen(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // call it later since it calls manageControlsAfterOnClick at the end | ||||
|         super.onClick(v); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     protected void onPlaybackSpeedClicked() { | ||||
|         final AppCompatActivity activity = getParentActivity().orElse(null); | ||||
|         if (activity == null) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         PlaybackParameterDialog.newInstance(player.getPlaybackSpeed(), player.getPlaybackPitch(), | ||||
|                 player.getPlaybackSkipSilence(), player::setPlaybackParameters) | ||||
|                 .show(activity.getSupportFragmentManager(), null); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public boolean onLongClick(final View v) { | ||||
|         if (v.getId() == binding.moreOptionsButton.getId() && isFullscreen) { | ||||
|             player.getFragmentListener().ifPresent( | ||||
|                     PlayerServiceEventListener::onMoreOptionsLongClicked); | ||||
|             hideControls(0, 0); | ||||
|             hideSystemUIIfNeeded(); | ||||
|             return true; | ||||
|         } | ||||
|         return super.onLongClick(v); | ||||
|         getParentActivity().ifPresent(activity -> | ||||
|                 PlaybackParameterDialog.newInstance(player.getPlaybackSpeed(), | ||||
|                                 player.getPlaybackPitch(), player.getPlaybackSkipSilence(), | ||||
|                                 player::setPlaybackParameters) | ||||
|                         .show(activity.getSupportFragmentManager(), null)); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
| @@ -984,22 +965,22 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh | ||||
|     //////////////////////////////////////////////////////////////////////////*/ | ||||
|     //region Getters | ||||
|  | ||||
|     private Optional<Context> getParentContext() { | ||||
|         return Optional.ofNullable(binding.getRoot().getParent()) | ||||
|                 .filter(ViewGroup.class::isInstance) | ||||
|                 .map(parent -> ((ViewGroup) parent).getContext()); | ||||
|     } | ||||
|  | ||||
|     public Optional<AppCompatActivity> getParentActivity() { | ||||
|         final ViewParent rootParent = binding.getRoot().getParent(); | ||||
|         if (rootParent instanceof ViewGroup) { | ||||
|             final Context activity = ((ViewGroup) rootParent).getContext(); | ||||
|             if (activity instanceof AppCompatActivity) { | ||||
|                 return Optional.of((AppCompatActivity) activity); | ||||
|             } | ||||
|         } | ||||
|         return Optional.empty(); | ||||
|         return getParentContext() | ||||
|                 .filter(AppCompatActivity.class::isInstance) | ||||
|                 .map(AppCompatActivity.class::cast); | ||||
|     } | ||||
|  | ||||
|     public boolean isLandscape() { | ||||
|         // DisplayMetrics from activity context knows about MultiWindow feature | ||||
|         // while DisplayMetrics from app context doesn't | ||||
|         return DeviceUtils.isLandscape( | ||||
|                 getParentActivity().map(Context.class::cast).orElse(player.getService())); | ||||
|         return DeviceUtils.isLandscape(getParentContext().orElse(player.getService())); | ||||
|     } | ||||
|     //endregion | ||||
| } | ||||
|   | ||||
| @@ -42,6 +42,7 @@ import androidx.annotation.Nullable; | ||||
| import androidx.appcompat.content.res.AppCompatResources; | ||||
| import androidx.appcompat.view.ContextThemeWrapper; | ||||
| import androidx.appcompat.widget.PopupMenu; | ||||
| import androidx.core.graphics.BitmapCompat; | ||||
| import androidx.core.graphics.Insets; | ||||
| import androidx.core.math.MathUtils; | ||||
| import androidx.core.view.ViewCompat; | ||||
| @@ -83,11 +84,11 @@ import org.schabi.newpipe.util.external_communication.ShareUtils; | ||||
| import org.schabi.newpipe.views.player.PlayerFastSeekOverlay; | ||||
|  | ||||
| import java.util.List; | ||||
| import java.util.Objects; | ||||
| import java.util.Optional; | ||||
| import java.util.stream.Collectors; | ||||
|  | ||||
| public abstract class VideoPlayerUi extends PlayerUi | ||||
|         implements SeekBar.OnSeekBarChangeListener, View.OnClickListener, View.OnLongClickListener, | ||||
| public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBarChangeListener, | ||||
|         PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener { | ||||
|     private static final String TAG = VideoPlayerUi.class.getSimpleName(); | ||||
|  | ||||
| @@ -131,9 +132,11 @@ public abstract class VideoPlayerUi extends PlayerUi | ||||
|  | ||||
|     private GestureDetector gestureDetector; | ||||
|     private BasePlayerGestureListener playerGestureListener; | ||||
|     @Nullable private View.OnLayoutChangeListener onLayoutChangeListener = null; | ||||
|     @Nullable | ||||
|     private View.OnLayoutChangeListener onLayoutChangeListener = null; | ||||
|  | ||||
|     @NonNull private final SeekbarPreviewThumbnailHolder seekbarPreviewThumbnailHolder = | ||||
|     @NonNull | ||||
|     private final SeekbarPreviewThumbnailHolder seekbarPreviewThumbnailHolder = | ||||
|             new SeekbarPreviewThumbnailHolder(); | ||||
|  | ||||
|  | ||||
| @@ -186,13 +189,13 @@ public abstract class VideoPlayerUi extends PlayerUi | ||||
|     abstract BasePlayerGestureListener buildGestureListener(); | ||||
|  | ||||
|     protected void initListeners() { | ||||
|         binding.qualityTextView.setOnClickListener(this); | ||||
|         binding.playbackSpeed.setOnClickListener(this); | ||||
|         binding.qualityTextView.setOnClickListener(makeOnClickListener(this::onQualityClicked)); | ||||
|         binding.playbackSpeed.setOnClickListener(makeOnClickListener(this::onPlaybackSpeedClicked)); | ||||
|  | ||||
|         binding.playbackSeekBar.setOnSeekBarChangeListener(this); | ||||
|         binding.captionTextView.setOnClickListener(this); | ||||
|         binding.resizeTextView.setOnClickListener(this); | ||||
|         binding.playbackLiveSync.setOnClickListener(this); | ||||
|         binding.captionTextView.setOnClickListener(makeOnClickListener(this::onCaptionClicked)); | ||||
|         binding.resizeTextView.setOnClickListener(makeOnClickListener(this::onResizeClicked)); | ||||
|         binding.playbackLiveSync.setOnClickListener(makeOnClickListener(player::seekToDefault)); | ||||
|  | ||||
|         playerGestureListener = buildGestureListener(); | ||||
|         gestureDetector = new GestureDetector(context, playerGestureListener); | ||||
| @@ -201,20 +204,36 @@ public abstract class VideoPlayerUi extends PlayerUi | ||||
|         binding.repeatButton.setOnClickListener(v -> onRepeatClicked()); | ||||
|         binding.shuffleButton.setOnClickListener(v -> onShuffleClicked()); | ||||
|  | ||||
|         binding.playPauseButton.setOnClickListener(this); | ||||
|         binding.playPreviousButton.setOnClickListener(this); | ||||
|         binding.playNextButton.setOnClickListener(this); | ||||
|         binding.playPauseButton.setOnClickListener(makeOnClickListener(player::playPause)); | ||||
|         binding.playPreviousButton.setOnClickListener(makeOnClickListener(player::playPrevious)); | ||||
|         binding.playNextButton.setOnClickListener(makeOnClickListener(player::playNext)); | ||||
|  | ||||
|         binding.moreOptionsButton.setOnClickListener(this); | ||||
|         binding.moreOptionsButton.setOnLongClickListener(this); | ||||
|         binding.share.setOnClickListener(this); | ||||
|         binding.share.setOnLongClickListener(this); | ||||
|         binding.fullScreenButton.setOnClickListener(this); | ||||
|         binding.screenRotationButton.setOnClickListener(this); | ||||
|         binding.playWithKodi.setOnClickListener(this); | ||||
|         binding.openInBrowser.setOnClickListener(this); | ||||
|         binding.playerCloseButton.setOnClickListener(this); | ||||
|         binding.switchMute.setOnClickListener(this); | ||||
|         binding.moreOptionsButton.setOnClickListener( | ||||
|                 makeOnClickListener(this::onMoreOptionsClicked)); | ||||
|         binding.share.setOnClickListener(makeOnClickListener(() -> { | ||||
|             final PlayQueueItem currentItem = player.getCurrentItem(); | ||||
|             if (currentItem != null) { | ||||
|                 ShareUtils.shareText(context, currentItem.getTitle(), | ||||
|                         player.getVideoUrlAtCurrentTime(), currentItem.getThumbnailUrl()); | ||||
|             } | ||||
|         })); | ||||
|         binding.share.setOnLongClickListener(v -> { | ||||
|             ShareUtils.copyToClipboard(context, player.getVideoUrlAtCurrentTime()); | ||||
|             return true; | ||||
|         }); | ||||
|         binding.fullScreenButton.setOnClickListener(makeOnClickListener(() -> { | ||||
|             player.setRecovery(); | ||||
|             NavigationHelper.playOnMainPlayer(context, | ||||
|                     Objects.requireNonNull(player.getPlayQueue()), true); | ||||
|         })); | ||||
|         binding.playWithKodi.setOnClickListener(makeOnClickListener(this::onPlayWithKodiClicked)); | ||||
|         binding.openInBrowser.setOnClickListener(makeOnClickListener(this::onOpenInBrowserClicked)); | ||||
|         binding.playerCloseButton.setOnClickListener(makeOnClickListener(() -> | ||||
|                 // set package to this app's package to prevent the intent from being seen outside | ||||
|                 context.sendBroadcast(new Intent(VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER) | ||||
|                         .setPackage(App.PACKAGE_NAME)) | ||||
|         )); | ||||
|         binding.switchMute.setOnClickListener(makeOnClickListener(player::toggleMute)); | ||||
|  | ||||
|         ViewCompat.setOnApplyWindowInsetsListener(binding.itemsListPanel, (view, windowInsets) -> { | ||||
|             final Insets cutout = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()); | ||||
| @@ -228,11 +247,8 @@ public abstract class VideoPlayerUi extends PlayerUi | ||||
|         // player_overlays and fast_seek_overlay too. Without it they will be off-centered. | ||||
|         onLayoutChangeListener = | ||||
|                 (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { | ||||
|                     binding.playerOverlays.setPadding( | ||||
|                             v.getPaddingLeft(), | ||||
|                             v.getPaddingTop(), | ||||
|                             v.getPaddingRight(), | ||||
|                             v.getPaddingBottom()); | ||||
|                     binding.playerOverlays.setPadding(v.getPaddingLeft(), v.getPaddingTop(), | ||||
|                             v.getPaddingRight(), v.getPaddingBottom()); | ||||
|  | ||||
|                     // If we added padding to the fast seek overlay, too, it would not go under the | ||||
|                     // system ui. Instead we apply negative margins equal to the window insets of | ||||
| @@ -455,10 +471,11 @@ public abstract class VideoPlayerUi extends PlayerUi | ||||
|         } | ||||
|  | ||||
|         final float endScreenHeight = calculateMaxEndScreenThumbnailHeight(thumbnail); | ||||
|         final Bitmap endScreenBitmap = Bitmap.createScaledBitmap( | ||||
|         final Bitmap endScreenBitmap = BitmapCompat.createScaledBitmap( | ||||
|                 thumbnail, | ||||
|                 (int) (thumbnail.getWidth() / (thumbnail.getHeight() / endScreenHeight)), | ||||
|                 (int) endScreenHeight, | ||||
|                 null, | ||||
|                 true); | ||||
|  | ||||
|         if (DEBUG) { | ||||
| @@ -549,7 +566,7 @@ public abstract class VideoPlayerUi extends PlayerUi | ||||
|         SeekbarPreviewThumbnailHelper | ||||
|                 .tryResizeAndSetSeekbarPreviewThumbnail( | ||||
|                         player.getContext(), | ||||
|                         seekbarPreviewThumbnailHolder.getBitmapAt(progress), | ||||
|                         seekbarPreviewThumbnailHolder.getBitmapAt(progress).orElse(null), | ||||
|                         binding.currentSeekbarPreviewThumbnail, | ||||
|                         binding.subtitleView::getWidth); | ||||
|  | ||||
| @@ -601,11 +618,6 @@ public abstract class VideoPlayerUi extends PlayerUi | ||||
|             player.changeState(STATE_PAUSED_SEEK); | ||||
|         } | ||||
|  | ||||
|         player.saveWasPlaying(); | ||||
|         if (player.isPlaying()) { | ||||
|             player.getExoPlayer().pause(); | ||||
|         } | ||||
|  | ||||
|         showControls(0); | ||||
|         animate(binding.currentDisplaySeek, true, DEFAULT_CONTROLS_DURATION, | ||||
|                 AnimationType.SCALE_AND_ALPHA); | ||||
| @@ -620,7 +632,7 @@ public abstract class VideoPlayerUi extends PlayerUi | ||||
|         } | ||||
|  | ||||
|         player.seekTo(seekBar.getProgress()); | ||||
|         if (player.wasPlaying() || player.getExoPlayer().getDuration() == seekBar.getProgress()) { | ||||
|         if (player.getExoPlayer().getDuration() == seekBar.getProgress()) { | ||||
|             player.getExoPlayer().play(); | ||||
|         } | ||||
|  | ||||
| @@ -634,9 +646,8 @@ public abstract class VideoPlayerUi extends PlayerUi | ||||
|         if (!player.isProgressLoopRunning()) { | ||||
|             player.startProgressLoop(); | ||||
|         } | ||||
|         if (player.wasPlaying()) { | ||||
|             showControlsThenHide(); | ||||
|         } | ||||
|  | ||||
|         showControlsThenHide(); | ||||
|     } | ||||
|     //endregion | ||||
|  | ||||
| @@ -971,61 +982,56 @@ public abstract class VideoPlayerUi extends PlayerUi | ||||
|     } | ||||
|  | ||||
|     private void updateStreamRelatedViews() { | ||||
|         //noinspection SimplifyOptionalCallChains | ||||
|         if (!player.getCurrentStreamInfo().isPresent()) { | ||||
|             return; | ||||
|         } | ||||
|         final StreamInfo info = player.getCurrentStreamInfo().get(); | ||||
|         player.getCurrentStreamInfo().ifPresent(info -> { | ||||
|             binding.qualityTextView.setVisibility(View.GONE); | ||||
|             binding.playbackSpeed.setVisibility(View.GONE); | ||||
|  | ||||
|         binding.qualityTextView.setVisibility(View.GONE); | ||||
|         binding.playbackSpeed.setVisibility(View.GONE); | ||||
|             binding.playbackEndTime.setVisibility(View.GONE); | ||||
|             binding.playbackLiveSync.setVisibility(View.GONE); | ||||
|  | ||||
|         binding.playbackEndTime.setVisibility(View.GONE); | ||||
|         binding.playbackLiveSync.setVisibility(View.GONE); | ||||
|  | ||||
|         switch (info.getStreamType()) { | ||||
|             case AUDIO_STREAM: | ||||
|             case POST_LIVE_AUDIO_STREAM: | ||||
|                 binding.surfaceView.setVisibility(View.GONE); | ||||
|                 binding.endScreen.setVisibility(View.VISIBLE); | ||||
|                 binding.playbackEndTime.setVisibility(View.VISIBLE); | ||||
|                 break; | ||||
|  | ||||
|             case AUDIO_LIVE_STREAM: | ||||
|                 binding.surfaceView.setVisibility(View.GONE); | ||||
|                 binding.endScreen.setVisibility(View.VISIBLE); | ||||
|                 binding.playbackLiveSync.setVisibility(View.VISIBLE); | ||||
|                 break; | ||||
|  | ||||
|             case LIVE_STREAM: | ||||
|                 binding.surfaceView.setVisibility(View.VISIBLE); | ||||
|                 binding.endScreen.setVisibility(View.GONE); | ||||
|                 binding.playbackLiveSync.setVisibility(View.VISIBLE); | ||||
|                 break; | ||||
|  | ||||
|             case VIDEO_STREAM: | ||||
|             case POST_LIVE_STREAM: | ||||
|                 //noinspection SimplifyOptionalCallChains | ||||
|                 if (player.getCurrentMetadata() != null | ||||
|                         && !player.getCurrentMetadata().getMaybeQuality().isPresent() | ||||
|                         || (info.getVideoStreams().isEmpty() | ||||
|                         && info.getVideoOnlyStreams().isEmpty())) { | ||||
|             switch (info.getStreamType()) { | ||||
|                 case AUDIO_STREAM: | ||||
|                 case POST_LIVE_AUDIO_STREAM: | ||||
|                     binding.surfaceView.setVisibility(View.GONE); | ||||
|                     binding.endScreen.setVisibility(View.VISIBLE); | ||||
|                     binding.playbackEndTime.setVisibility(View.VISIBLE); | ||||
|                     break; | ||||
|                 } | ||||
|  | ||||
|                 buildQualityMenu(); | ||||
|                 case AUDIO_LIVE_STREAM: | ||||
|                     binding.surfaceView.setVisibility(View.GONE); | ||||
|                     binding.endScreen.setVisibility(View.VISIBLE); | ||||
|                     binding.playbackLiveSync.setVisibility(View.VISIBLE); | ||||
|                     break; | ||||
|  | ||||
|                 binding.qualityTextView.setVisibility(View.VISIBLE); | ||||
|                 binding.surfaceView.setVisibility(View.VISIBLE); | ||||
|                 // fallthrough | ||||
|             default: | ||||
|                 binding.endScreen.setVisibility(View.GONE); | ||||
|                 binding.playbackEndTime.setVisibility(View.VISIBLE); | ||||
|                 break; | ||||
|         } | ||||
|                 case LIVE_STREAM: | ||||
|                     binding.surfaceView.setVisibility(View.VISIBLE); | ||||
|                     binding.endScreen.setVisibility(View.GONE); | ||||
|                     binding.playbackLiveSync.setVisibility(View.VISIBLE); | ||||
|                     break; | ||||
|  | ||||
|         buildPlaybackSpeedMenu(); | ||||
|         binding.playbackSpeed.setVisibility(View.VISIBLE); | ||||
|                 case VIDEO_STREAM: | ||||
|                 case POST_LIVE_STREAM: | ||||
|                     if (player.getCurrentMetadata() != null | ||||
|                             && player.getCurrentMetadata().getMaybeQuality().isEmpty() | ||||
|                             || (info.getVideoStreams().isEmpty() | ||||
|                             && info.getVideoOnlyStreams().isEmpty())) { | ||||
|                         break; | ||||
|                     } | ||||
|  | ||||
|                     buildQualityMenu(); | ||||
|  | ||||
|                     binding.qualityTextView.setVisibility(View.VISIBLE); | ||||
|                     binding.surfaceView.setVisibility(View.VISIBLE); | ||||
|                     // fallthrough | ||||
|                 default: | ||||
|                     binding.endScreen.setVisibility(View.GONE); | ||||
|                     binding.playbackEndTime.setVisibility(View.VISIBLE); | ||||
|                     break; | ||||
|             } | ||||
|  | ||||
|             buildPlaybackSpeedMenu(); | ||||
|             binding.playbackSpeed.setVisibility(View.VISIBLE); | ||||
|         }); | ||||
|     } | ||||
|     //endregion | ||||
|  | ||||
| @@ -1054,12 +1060,11 @@ public abstract class VideoPlayerUi extends PlayerUi | ||||
|             qualityPopupMenu.getMenu().add(POPUP_MENU_ID_QUALITY, i, Menu.NONE, MediaFormat | ||||
|                     .getNameById(videoStream.getFormatId()) + " " + videoStream.getResolution()); | ||||
|         } | ||||
|         final VideoStream selectedVideoStream = player.getSelectedVideoStream(); | ||||
|         if (selectedVideoStream != null) { | ||||
|             binding.qualityTextView.setText(selectedVideoStream.getResolution()); | ||||
|         } | ||||
|         qualityPopupMenu.setOnMenuItemClickListener(this); | ||||
|         qualityPopupMenu.setOnDismissListener(this); | ||||
|  | ||||
|         player.getSelectedVideoStream() | ||||
|                 .ifPresent(s -> binding.qualityTextView.setText(s.getResolution())); | ||||
|     } | ||||
|  | ||||
|     private void buildPlaybackSpeedMenu() { | ||||
| @@ -1165,14 +1170,9 @@ public abstract class VideoPlayerUi extends PlayerUi | ||||
|         qualityPopupMenu.show(); | ||||
|         isSomePopupMenuVisible = true; | ||||
|  | ||||
|         final VideoStream videoStream = player.getSelectedVideoStream(); | ||||
|         if (videoStream != null) { | ||||
|             //noinspection SetTextI18n | ||||
|             binding.qualityTextView.setText(MediaFormat.getNameById(videoStream.getFormatId()) | ||||
|                     + " " + videoStream.getResolution()); | ||||
|         } | ||||
|  | ||||
|         player.saveWasPlaying(); | ||||
|         player.getSelectedVideoStream() | ||||
|                 .map(s -> MediaFormat.getNameById(s.getFormatId()) + " " + s.getResolution()) | ||||
|                 .ifPresent(binding.qualityTextView::setText); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -1189,8 +1189,7 @@ public abstract class VideoPlayerUi extends PlayerUi | ||||
|         if (menuItem.getGroupId() == POPUP_MENU_ID_QUALITY) { | ||||
|             final int menuItemIndex = menuItem.getItemId(); | ||||
|             @Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata(); | ||||
|             //noinspection SimplifyOptionalCallChains | ||||
|             if (currentMetadata == null || !currentMetadata.getMaybeQuality().isPresent()) { | ||||
|             if (currentMetadata == null || currentMetadata.getMaybeQuality().isEmpty()) { | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
| @@ -1229,10 +1228,9 @@ public abstract class VideoPlayerUi extends PlayerUi | ||||
|             Log.d(TAG, "onDismiss() called with: menu = [" + menu + "]"); | ||||
|         } | ||||
|         isSomePopupMenuVisible = false; //TODO check if this works | ||||
|         final VideoStream selectedVideoStream = player.getSelectedVideoStream(); | ||||
|         if (selectedVideoStream != null) { | ||||
|             binding.qualityTextView.setText(selectedVideoStream.getResolution()); | ||||
|         } | ||||
|         player.getSelectedVideoStream() | ||||
|                 .ifPresent(s -> binding.qualityTextView.setText(s.getResolution())); | ||||
|  | ||||
|         if (player.isPlaying()) { | ||||
|             hideControls(DEFAULT_CONTROLS_DURATION, 0); | ||||
|             hideSystemUIIfNeeded(); | ||||
| @@ -1291,9 +1289,8 @@ public abstract class VideoPlayerUi extends PlayerUi | ||||
|  | ||||
|         // Build UI | ||||
|         buildCaptionMenu(availableLanguages); | ||||
|         //noinspection SimplifyOptionalCallChains | ||||
|         if (player.getTrackSelector().getParameters().getRendererDisabled( | ||||
|                 player.getCaptionRendererIndex()) || !selectedTracks.isPresent()) { | ||||
|                 player.getCaptionRendererIndex()) || selectedTracks.isEmpty()) { | ||||
|             binding.captionTextView.setText(R.string.caption_none); | ||||
|         } else { | ||||
|             binding.captionTextView.setText(selectedTracks.get().language); | ||||
| @@ -1324,86 +1321,39 @@ public abstract class VideoPlayerUi extends PlayerUi | ||||
|     //////////////////////////////////////////////////////////////////////////*/ | ||||
|     //region Click listeners | ||||
|  | ||||
|     @Override | ||||
|     public void onClick(final View v) { | ||||
|         if (DEBUG) { | ||||
|             Log.d(TAG, "onClick() called with: v = [" + v + "]"); | ||||
|         } | ||||
|         if (v.getId() == binding.resizeTextView.getId()) { | ||||
|             onResizeClicked(); | ||||
|         } else if (v.getId() == binding.captionTextView.getId()) { | ||||
|             onCaptionClicked(); | ||||
|         } else if (v.getId() == binding.playbackLiveSync.getId()) { | ||||
|             player.seekToDefault(); | ||||
|         } else if (v.getId() == binding.playPauseButton.getId()) { | ||||
|             player.playPause(); | ||||
|         } else if (v.getId() == binding.playPreviousButton.getId()) { | ||||
|             player.playPrevious(); | ||||
|         } else if (v.getId() == binding.playNextButton.getId()) { | ||||
|             player.playNext(); | ||||
|         } else if (v.getId() == binding.moreOptionsButton.getId()) { | ||||
|             onMoreOptionsClicked(); | ||||
|         } else if (v.getId() == binding.share.getId()) { | ||||
|             final PlayQueueItem currentItem = player.getCurrentItem(); | ||||
|             if (currentItem != null) { | ||||
|                 ShareUtils.shareText(context, currentItem.getTitle(), | ||||
|                         player.getVideoUrlAtCurrentTime(), currentItem.getThumbnailUrl()); | ||||
|             } | ||||
|         } else if (v.getId() == binding.playWithKodi.getId()) { | ||||
|             onPlayWithKodiClicked(); | ||||
|         } else if (v.getId() == binding.openInBrowser.getId()) { | ||||
|             onOpenInBrowserClicked(); | ||||
|         } else if (v.getId() == binding.fullScreenButton.getId()) { | ||||
|             player.setRecovery(); | ||||
|             NavigationHelper.playOnMainPlayer(context, player.getPlayQueue(), true); | ||||
|             return; | ||||
|         } else if (v.getId() == binding.switchMute.getId()) { | ||||
|             player.toggleMute(); | ||||
|         } else if (v.getId() == binding.playerCloseButton.getId()) { | ||||
|             // set package to this app's package to prevent the intent from being seen outside | ||||
|             context.sendBroadcast(new Intent(VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER) | ||||
|                     .setPackage(App.PACKAGE_NAME)); | ||||
|         } else if (v.getId() == binding.playbackSpeed.getId()) { | ||||
|             onPlaybackSpeedClicked(); | ||||
|         } else if (v.getId() == binding.qualityTextView.getId()) { | ||||
|             onQualityClicked(); | ||||
|         } | ||||
|  | ||||
|         manageControlsAfterOnClick(v); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Manages the controls after a click occurred on the player UI. | ||||
|      * @param v – The view that was clicked | ||||
|      * Create on-click listener which manages the player controls after the view on-click action. | ||||
|      * | ||||
|      * @param runnable The action to be executed. | ||||
|      * @return The view click listener. | ||||
|      */ | ||||
|     public void manageControlsAfterOnClick(@NonNull final View v) { | ||||
|         if (player.getCurrentState() == STATE_COMPLETED) { | ||||
|             return; | ||||
|         } | ||||
|     protected View.OnClickListener makeOnClickListener(@NonNull final Runnable runnable) { | ||||
|         return v -> { | ||||
|             if (DEBUG) { | ||||
|                 Log.d(TAG, "onClick() called with: v = [" + v + "]"); | ||||
|             } | ||||
|  | ||||
|         controlsVisibilityHandler.removeCallbacksAndMessages(null); | ||||
|         showHideShadow(true, DEFAULT_CONTROLS_DURATION); | ||||
|         animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION, | ||||
|                 AnimationType.ALPHA, 0, () -> { | ||||
|                     if (player.getCurrentState() == STATE_PLAYING && !isSomePopupMenuVisible) { | ||||
|                         if (v.getId() == binding.playPauseButton.getId() | ||||
|                                 // Hide controls in fullscreen immediately | ||||
|                                 || (v.getId() == binding.screenRotationButton.getId() | ||||
|                                 && isFullscreen())) { | ||||
|                             hideControls(0, 0); | ||||
|                         } else { | ||||
|                             hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); | ||||
|             runnable.run(); | ||||
|  | ||||
|             // Manages the player controls after handling the view click. | ||||
|             if (player.getCurrentState() == STATE_COMPLETED) { | ||||
|                 return; | ||||
|             } | ||||
|             controlsVisibilityHandler.removeCallbacksAndMessages(null); | ||||
|             showHideShadow(true, DEFAULT_CONTROLS_DURATION); | ||||
|             animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION, | ||||
|                     AnimationType.ALPHA, 0, () -> { | ||||
|                         if (player.getCurrentState() == STATE_PLAYING && !isSomePopupMenuVisible) { | ||||
|                             if (v == binding.playPauseButton | ||||
|                                     // Hide controls in fullscreen immediately | ||||
|                                     || (v == binding.screenRotationButton && isFullscreen())) { | ||||
|                                 hideControls(0, 0); | ||||
|                             } else { | ||||
|                                 hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 }); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public boolean onLongClick(final View v) { | ||||
|         if (v.getId() == binding.share.getId()) { | ||||
|             ShareUtils.copyToClipboard(context, player.getVideoUrlAtCurrentTime()); | ||||
|         } | ||||
|         return true; | ||||
|                     }); | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     public boolean onKeyDown(final int keyCode) { | ||||
|   | ||||
| @@ -44,7 +44,13 @@ public class AppearanceSettingsFragment extends BasePreferenceFragment { | ||||
|                 return false; | ||||
|             }); | ||||
|         } else { | ||||
|             removePreference(nightThemeKey); | ||||
|             // disable the night theme selection | ||||
|             final Preference preference = findPreference(nightThemeKey); | ||||
|             if (preference != null) { | ||||
|                 preference.setEnabled(false); | ||||
|                 preference.setSummary(getString(R.string.night_theme_available, | ||||
|                         getString(R.string.auto_device_theme_title))); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -61,13 +67,6 @@ public class AppearanceSettingsFragment extends BasePreferenceFragment { | ||||
|         return super.onPreferenceTreeClick(preference); | ||||
|     } | ||||
|  | ||||
|     private void removePreference(final String preferenceKey) { | ||||
|         final Preference preference = findPreference(preferenceKey); | ||||
|         if (preference != null) { | ||||
|             getPreferenceScreen().removePreference(preference); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private void applyThemeChange(final String beginningThemeKey, | ||||
|                                   final String themeKey, | ||||
|                                   final Object newValue) { | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| package org.schabi.newpipe.settings; | ||||
|  | ||||
| import static org.schabi.newpipe.extractor.utils.Utils.decodeUrlUtf8; | ||||
| import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; | ||||
|  | ||||
| import android.app.Activity; | ||||
| @@ -31,8 +32,6 @@ import java.io.File; | ||||
| import java.io.IOException; | ||||
| import java.io.UnsupportedEncodingException; | ||||
| import java.net.URI; | ||||
| import java.net.URLDecoder; | ||||
| import java.nio.charset.StandardCharsets; | ||||
|  | ||||
| public class DownloadSettingsFragment extends BasePreferenceFragment { | ||||
|     public static final boolean IGNORE_RELEASE_ON_OLD_PATH = true; | ||||
| @@ -125,7 +124,7 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             rawUri = URLDecoder.decode(rawUri, StandardCharsets.UTF_8.name()); | ||||
|             rawUri = decodeUrlUtf8(rawUri); | ||||
|         } catch (final UnsupportedEncodingException e) { | ||||
|             // nothing to do | ||||
|         } | ||||
|   | ||||
| @@ -16,25 +16,17 @@ public class UpdateSettingsFragment extends BasePreferenceFragment { | ||||
|                 .apply(); | ||||
|  | ||||
|         if (checkForUpdates) { | ||||
|             checkNewVersionNow(); | ||||
|             NewVersionWorker.enqueueNewVersionCheckingWork(requireContext(), true); | ||||
|         } | ||||
|         return true; | ||||
|     }; | ||||
|  | ||||
|     private final Preference.OnPreferenceClickListener manualUpdateClick = preference -> { | ||||
|         Toast.makeText(getContext(), R.string.checking_updates_toast, Toast.LENGTH_SHORT).show(); | ||||
|         checkNewVersionNow(); | ||||
|         NewVersionWorker.enqueueNewVersionCheckingWork(requireContext(), true); | ||||
|         return true; | ||||
|     }; | ||||
|  | ||||
|     private void checkNewVersionNow() { | ||||
|         // Search for updates immediately when update checks are enabled. | ||||
|         // Reset the expire time. This is necessary to check for an update immediately. | ||||
|         defaultPreferences.edit() | ||||
|                 .putLong(getString(R.string.update_expiry_key), 0).apply(); | ||||
|         NewVersionWorker.enqueueNewVersionCheckingWork(getContext()); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { | ||||
|         addPreferencesFromResourceRegistry(); | ||||
|   | ||||
| @@ -27,14 +27,14 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment { | ||||
|  | ||||
|         updateSeekOptions(); | ||||
|  | ||||
|         listener = (sharedPreferences, s) -> { | ||||
|         listener = (sharedPreferences, key) -> { | ||||
|  | ||||
|             // on M and above, if user chooses to minimise to popup player on exit | ||||
|             // and the app doesn't have display over other apps permission, | ||||
|             // show a snackbar to let the user give permission | ||||
|             if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M | ||||
|                     && s.equals(getString(R.string.minimize_on_exit_key))) { | ||||
|                 final String newSetting = sharedPreferences.getString(s, null); | ||||
|                     && getString(R.string.minimize_on_exit_key).equals(key)) { | ||||
|                 final String newSetting = sharedPreferences.getString(key, null); | ||||
|                 if (newSetting != null | ||||
|                         && newSetting.equals(getString(R.string.minimize_on_exit_popup_key)) | ||||
|                         && !Settings.canDrawOverlays(getContext())) { | ||||
| @@ -46,7 +46,7 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment { | ||||
|                             .show(); | ||||
|  | ||||
|                 } | ||||
|             } else if (s.equals(getString(R.string.use_inexact_seek_key))) { | ||||
|             } else if (getString(R.string.use_inexact_seek_key).equals(key)) { | ||||
|                 updateSeekOptions(); | ||||
|             } | ||||
|         }; | ||||
|   | ||||
| @@ -1,15 +1,13 @@ | ||||
| package org.schabi.newpipe.settings.notifications | ||||
|  | ||||
| import android.view.LayoutInflater | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import android.widget.CheckedTextView | ||||
| import androidx.recyclerview.widget.AsyncListDiffer | ||||
| import androidx.recyclerview.widget.DiffUtil | ||||
| import androidx.recyclerview.widget.ListAdapter | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| import org.schabi.newpipe.R | ||||
| import org.schabi.newpipe.database.subscription.NotificationMode | ||||
| import org.schabi.newpipe.database.subscription.SubscriptionEntity | ||||
| import org.schabi.newpipe.databinding.ItemNotificationConfigBinding | ||||
| import org.schabi.newpipe.settings.notifications.NotificationModeConfigAdapter.SubscriptionHolder | ||||
|  | ||||
| /** | ||||
| @@ -19,85 +17,46 @@ import org.schabi.newpipe.settings.notifications.NotificationModeConfigAdapter.S | ||||
|  */ | ||||
| class NotificationModeConfigAdapter( | ||||
|     private val listener: ModeToggleListener | ||||
| ) : RecyclerView.Adapter<SubscriptionHolder>() { | ||||
|  | ||||
|     private val differ = AsyncListDiffer(this, DiffCallback()) | ||||
|  | ||||
|     init { | ||||
|         setHasStableIds(true) | ||||
|     } | ||||
|  | ||||
|     override fun onCreateViewHolder(viewGroup: ViewGroup, i: Int): SubscriptionHolder { | ||||
|         val view = LayoutInflater.from(viewGroup.context) | ||||
|             .inflate(R.layout.item_notification_config, viewGroup, false) | ||||
|         return SubscriptionHolder(view, listener) | ||||
|     } | ||||
|  | ||||
|     override fun onBindViewHolder(subscriptionHolder: SubscriptionHolder, i: Int) { | ||||
|         subscriptionHolder.bind(differ.currentList[i]) | ||||
|     } | ||||
|  | ||||
|     fun getItem(position: Int): SubscriptionItem = differ.currentList[position] | ||||
|  | ||||
|     override fun getItemCount() = differ.currentList.size | ||||
|  | ||||
|     override fun getItemId(position: Int): Long { | ||||
|         return differ.currentList[position].id | ||||
|     } | ||||
|  | ||||
|     fun getCurrentList(): List<SubscriptionItem> = differ.currentList | ||||
|  | ||||
|     fun update(newData: List<SubscriptionEntity>) { | ||||
|         differ.submitList( | ||||
|             newData.map { | ||||
|                 SubscriptionItem( | ||||
|                     id = it.uid, | ||||
|                     title = it.name, | ||||
|                     notificationMode = it.notificationMode, | ||||
|                     serviceId = it.serviceId, | ||||
|                     url = it.url | ||||
|                 ) | ||||
|             } | ||||
| ) : ListAdapter<SubscriptionItem, SubscriptionHolder>(DiffCallback) { | ||||
|     override fun onCreateViewHolder(parent: ViewGroup, i: Int): SubscriptionHolder { | ||||
|         return SubscriptionHolder( | ||||
|             ItemNotificationConfigBinding | ||||
|                 .inflate(LayoutInflater.from(parent.context), parent, false) | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     data class SubscriptionItem( | ||||
|         val id: Long, | ||||
|         val title: String, | ||||
|         @NotificationMode | ||||
|         val notificationMode: Int, | ||||
|         val serviceId: Int, | ||||
|         val url: String | ||||
|     ) | ||||
|     override fun onBindViewHolder(holder: SubscriptionHolder, position: Int) { | ||||
|         holder.bind(currentList[position]) | ||||
|     } | ||||
|  | ||||
|     class SubscriptionHolder( | ||||
|         itemView: View, | ||||
|         private val listener: ModeToggleListener | ||||
|     ) : RecyclerView.ViewHolder(itemView), View.OnClickListener { | ||||
|  | ||||
|         private val checkedTextView = itemView as CheckedTextView | ||||
|     fun update(newData: List<SubscriptionEntity>) { | ||||
|         val items = newData.map { | ||||
|             SubscriptionItem(it.uid, it.name, it.notificationMode, it.serviceId, it.url) | ||||
|         } | ||||
|         submitList(items) | ||||
|     } | ||||
|  | ||||
|     inner class SubscriptionHolder( | ||||
|         private val itemBinding: ItemNotificationConfigBinding | ||||
|     ) : RecyclerView.ViewHolder(itemBinding.root) { | ||||
|         init { | ||||
|             itemView.setOnClickListener(this) | ||||
|             itemView.setOnClickListener { | ||||
|                 val mode = if (itemBinding.root.isChecked) { | ||||
|                     NotificationMode.DISABLED | ||||
|                 } else { | ||||
|                     NotificationMode.ENABLED | ||||
|                 } | ||||
|                 listener.onModeChange(bindingAdapterPosition, mode) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         fun bind(data: SubscriptionItem) { | ||||
|             checkedTextView.text = data.title | ||||
|             checkedTextView.isChecked = data.notificationMode != NotificationMode.DISABLED | ||||
|         } | ||||
|  | ||||
|         override fun onClick(v: View) { | ||||
|             val mode = if (checkedTextView.isChecked) { | ||||
|                 NotificationMode.DISABLED | ||||
|             } else { | ||||
|                 NotificationMode.ENABLED | ||||
|             } | ||||
|             listener.onModeChange(bindingAdapterPosition, mode) | ||||
|             itemBinding.root.text = data.title | ||||
|             itemBinding.root.isChecked = data.notificationMode != NotificationMode.DISABLED | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private class DiffCallback : DiffUtil.ItemCallback<SubscriptionItem>() { | ||||
|  | ||||
|     private object DiffCallback : DiffUtil.ItemCallback<SubscriptionItem>() { | ||||
|         override fun areItemsTheSame(oldItem: SubscriptionItem, newItem: SubscriptionItem): Boolean { | ||||
|             return oldItem.id == newItem.id | ||||
|         } | ||||
| @@ -107,18 +66,27 @@ class NotificationModeConfigAdapter( | ||||
|         } | ||||
|  | ||||
|         override fun getChangePayload(oldItem: SubscriptionItem, newItem: SubscriptionItem): Any? { | ||||
|             if (oldItem.notificationMode != newItem.notificationMode) { | ||||
|                 return newItem.notificationMode | ||||
|             return if (oldItem.notificationMode != newItem.notificationMode) { | ||||
|                 newItem.notificationMode | ||||
|             } else { | ||||
|                 return super.getChangePayload(oldItem, newItem) | ||||
|                 super.getChangePayload(oldItem, newItem) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     interface ModeToggleListener { | ||||
|     fun interface ModeToggleListener { | ||||
|         /** | ||||
|          * Triggered when the UI representation of a notification mode is changed. | ||||
|          */ | ||||
|         fun onModeChange(position: Int, @NotificationMode mode: Int) | ||||
|     } | ||||
| } | ||||
|  | ||||
| data class SubscriptionItem( | ||||
|     val id: Long, | ||||
|     val title: String, | ||||
|     @NotificationMode | ||||
|     val notificationMode: Int, | ||||
|     val serviceId: Int, | ||||
|     val url: String | ||||
| ) | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| package org.schabi.newpipe.settings.notifications | ||||
|  | ||||
| import android.content.Context | ||||
| import android.os.Bundle | ||||
| import android.view.LayoutInflater | ||||
| import android.view.Menu | ||||
| @@ -8,30 +9,36 @@ import android.view.MenuItem | ||||
| import android.view.View | ||||
| import android.view.ViewGroup | ||||
| import androidx.fragment.app.Fragment | ||||
| import androidx.recyclerview.widget.RecyclerView | ||||
| import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers | ||||
| import io.reactivex.rxjava3.disposables.CompositeDisposable | ||||
| import io.reactivex.rxjava3.disposables.Disposable | ||||
| import io.reactivex.rxjava3.schedulers.Schedulers | ||||
| import org.schabi.newpipe.R | ||||
| import org.schabi.newpipe.database.subscription.NotificationMode | ||||
| import org.schabi.newpipe.databinding.FragmentChannelsNotificationsBinding | ||||
| import org.schabi.newpipe.local.subscription.SubscriptionManager | ||||
| import org.schabi.newpipe.settings.notifications.NotificationModeConfigAdapter.ModeToggleListener | ||||
|  | ||||
| /** | ||||
|  * [NotificationModeConfigFragment] is a settings fragment | ||||
|  * which allows changing the [NotificationMode] of all subscribed channels. | ||||
|  * The [NotificationMode] can either be changed one by one or toggled for all channels. | ||||
|  */ | ||||
| class NotificationModeConfigFragment : Fragment(), ModeToggleListener { | ||||
| class NotificationModeConfigFragment : Fragment() { | ||||
|     private var _binding: FragmentChannelsNotificationsBinding? = null | ||||
|     private val binding get() = _binding!! | ||||
|  | ||||
|     private lateinit var updaters: CompositeDisposable | ||||
|     private val disposables = CompositeDisposable() | ||||
|     private var loader: Disposable? = null | ||||
|     private var adapter: NotificationModeConfigAdapter? = null | ||||
|     private lateinit var adapter: NotificationModeConfigAdapter | ||||
|     private lateinit var subscriptionManager: SubscriptionManager | ||||
|  | ||||
|     override fun onAttach(context: Context) { | ||||
|         super.onAttach(context) | ||||
|         subscriptionManager = SubscriptionManager(context) | ||||
|     } | ||||
|  | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         super.onCreate(savedInstanceState) | ||||
|         updaters = CompositeDisposable() | ||||
|         setHasOptionsMenu(true) | ||||
|     } | ||||
|  | ||||
| @@ -39,28 +46,34 @@ class NotificationModeConfigFragment : Fragment(), ModeToggleListener { | ||||
|         inflater: LayoutInflater, | ||||
|         container: ViewGroup?, | ||||
|         savedInstanceState: Bundle?, | ||||
|     ): View = inflater.inflate(R.layout.fragment_channels_notifications, container, false) | ||||
|     ): View { | ||||
|         _binding = FragmentChannelsNotificationsBinding.inflate(inflater, container, false) | ||||
|         return binding.root | ||||
|     } | ||||
|  | ||||
|     override fun onViewCreated(view: View, savedInstanceState: Bundle?) { | ||||
|         super.onViewCreated(view, savedInstanceState) | ||||
|         val recyclerView: RecyclerView = view.findViewById(R.id.recycler_view) | ||||
|         adapter = NotificationModeConfigAdapter(this) | ||||
|         recyclerView.adapter = adapter | ||||
|         adapter = NotificationModeConfigAdapter { position, mode -> | ||||
|             // Notification mode has been changed via the UI. | ||||
|             // Now change it in the database. | ||||
|             updateNotificationMode(adapter.currentList[position], mode) | ||||
|         } | ||||
|         binding.recyclerView.adapter = adapter | ||||
|         loader?.dispose() | ||||
|         loader = SubscriptionManager(requireContext()) | ||||
|             .subscriptions() | ||||
|         loader = subscriptionManager.subscriptions() | ||||
|             .observeOn(AndroidSchedulers.mainThread()) | ||||
|             .subscribe { newData -> adapter?.update(newData) } | ||||
|             .subscribe(adapter::update) | ||||
|     } | ||||
|  | ||||
|     override fun onDestroyView() { | ||||
|         loader?.dispose() | ||||
|         loader = null | ||||
|         _binding = null | ||||
|         super.onDestroyView() | ||||
|     } | ||||
|  | ||||
|     override fun onDestroy() { | ||||
|         updaters.dispose() | ||||
|         disposables.dispose() | ||||
|         super.onDestroy() | ||||
|     } | ||||
|  | ||||
| @@ -79,41 +92,20 @@ class NotificationModeConfigFragment : Fragment(), ModeToggleListener { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onModeChange(position: Int, @NotificationMode mode: Int) { | ||||
|         // Notification mode has been changed via the UI. | ||||
|         // Now change it in the database. | ||||
|         val subscription = adapter?.getItem(position) ?: return | ||||
|         updaters.add( | ||||
|             SubscriptionManager(requireContext()) | ||||
|                 .updateNotificationMode( | ||||
|                     subscription.serviceId, | ||||
|                     subscription.url, | ||||
|                     mode | ||||
|                 ) | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .subscribe() | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     private fun toggleAll() { | ||||
|         val subscriptions = adapter?.getCurrentList() ?: return | ||||
|         val mode = subscriptions.firstOrNull()?.notificationMode ?: return | ||||
|         val mode = adapter.currentList.firstOrNull()?.notificationMode ?: return | ||||
|         val newMode = when (mode) { | ||||
|             NotificationMode.DISABLED -> NotificationMode.ENABLED | ||||
|             else -> NotificationMode.DISABLED | ||||
|         } | ||||
|         val subscriptionManager = SubscriptionManager(requireContext()) | ||||
|         updaters.add( | ||||
|             CompositeDisposable( | ||||
|                 subscriptions.map { item -> | ||||
|                     subscriptionManager.updateNotificationMode( | ||||
|                         serviceId = item.serviceId, | ||||
|                         url = item.url, | ||||
|                         mode = newMode | ||||
|                     ).subscribeOn(Schedulers.io()) | ||||
|                         .subscribe() | ||||
|                 } | ||||
|             ) | ||||
|         adapter.currentList.forEach { updateNotificationMode(it, newMode) } | ||||
|     } | ||||
|  | ||||
|     private fun updateNotificationMode(item: SubscriptionItem, @NotificationMode mode: Int) { | ||||
|         disposables.add( | ||||
|             subscriptionManager.updateNotificationMode(item.serviceId, item.url, mode) | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .subscribe() | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -248,7 +248,7 @@ public abstract class Tab { | ||||
|         @DrawableRes | ||||
|         @Override | ||||
|         public int getTabIconRes(final Context context) { | ||||
|             return R.drawable.ic_rss_feed; | ||||
|             return R.drawable.ic_subscriptions; | ||||
|         } | ||||
|  | ||||
|         @Override | ||||
|   | ||||
| @@ -20,6 +20,7 @@ public final class TabsJsonHelper { | ||||
|  | ||||
|     private static final List<Tab> FALLBACK_INITIAL_TABS_LIST = List.of( | ||||
|             Tab.Type.DEFAULT_KIOSK.getTab(), | ||||
|             Tab.Type.FEED.getTab(), | ||||
|             Tab.Type.SUBSCRIPTIONS.getTab(), | ||||
|             Tab.Type.BOOKMARKS.getTab()); | ||||
|  | ||||
|   | ||||
| @@ -73,7 +73,7 @@ public final class TabsManager { | ||||
|  | ||||
|     private SharedPreferences.OnSharedPreferenceChangeListener getPreferenceChangeListener() { | ||||
|         return (sp, key) -> { | ||||
|             if (key.equals(savedTabsKey)) { | ||||
|             if (savedTabsKey.equals(key)) { | ||||
|                 if (savedTabsChangeListener != null) { | ||||
|                     savedTabsChangeListener.onTabsChanged(); | ||||
|                 } | ||||
|   | ||||
| @@ -652,7 +652,7 @@ public class WebMWriter implements Closeable { | ||||
|  | ||||
|         final int offset = withLength ? 1 : 0; | ||||
|         final byte[] buffer = new byte[offset + length]; | ||||
|         final long marker = (long) Math.floor((length - 1f) / 8f); | ||||
|         final long marker = Math.floorDiv(length - 1, 8); | ||||
|  | ||||
|         int shift = 0; | ||||
|         for (int i = length - 1; i >= 0; i--, shift += 8) { | ||||
|   | ||||
| @@ -1,71 +0,0 @@ | ||||
| package org.schabi.newpipe.util; | ||||
|  | ||||
| import android.text.Layout; | ||||
| import android.text.Selection; | ||||
| import android.text.Spannable; | ||||
| import android.text.Spanned; | ||||
| import android.text.style.ClickableSpan; | ||||
| import android.text.style.URLSpan; | ||||
| import android.view.MotionEvent; | ||||
| import android.view.View; | ||||
| import android.widget.TextView; | ||||
|  | ||||
| import org.schabi.newpipe.util.external_communication.ShareUtils; | ||||
| import org.schabi.newpipe.util.external_communication.InternalUrlsHandler; | ||||
|  | ||||
| import io.reactivex.rxjava3.disposables.CompositeDisposable; | ||||
|  | ||||
| public class CommentTextOnTouchListener implements View.OnTouchListener { | ||||
|     public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener(); | ||||
|  | ||||
|     @Override | ||||
|     public boolean onTouch(final View v, final MotionEvent event) { | ||||
|         if (!(v instanceof TextView)) { | ||||
|             return false; | ||||
|         } | ||||
|         final TextView widget = (TextView) v; | ||||
|         final Object text = widget.getText(); | ||||
|         if (text instanceof Spanned) { | ||||
|             final Spannable buffer = (Spannable) text; | ||||
|  | ||||
|             final int action = event.getAction(); | ||||
|  | ||||
|             if (action == MotionEvent.ACTION_UP | ||||
|                     || action == MotionEvent.ACTION_DOWN) { | ||||
|                 int x = (int) event.getX(); | ||||
|                 int y = (int) event.getY(); | ||||
|  | ||||
|                 x -= widget.getTotalPaddingLeft(); | ||||
|                 y -= widget.getTotalPaddingTop(); | ||||
|  | ||||
|                 x += widget.getScrollX(); | ||||
|                 y += widget.getScrollY(); | ||||
|  | ||||
|                 final Layout layout = widget.getLayout(); | ||||
|                 final int line = layout.getLineForVertical(y); | ||||
|                 final int off = layout.getOffsetForHorizontal(line, x); | ||||
|  | ||||
|                 final ClickableSpan[] link = buffer.getSpans(off, off, | ||||
|                         ClickableSpan.class); | ||||
|  | ||||
|                 if (link.length != 0) { | ||||
|                     if (action == MotionEvent.ACTION_UP) { | ||||
|                         if (link[0] instanceof URLSpan) { | ||||
|                             final String url = ((URLSpan) link[0]).getURL(); | ||||
|                             if (!InternalUrlsHandler.handleUrlCommentsTimestamp( | ||||
|                                     new CompositeDisposable(), v.getContext(), url)) { | ||||
|                                 ShareUtils.openUrlInBrowser(v.getContext(), url, false); | ||||
|                             } | ||||
|                         } | ||||
|                     } else if (action == MotionEvent.ACTION_DOWN) { | ||||
|                         Selection.setSelection(buffer, | ||||
|                                 buffer.getSpanStart(link[0]), | ||||
|                                 buffer.getSpanEnd(link[0])); | ||||
|                     } | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
| } | ||||
| @@ -1,14 +1,19 @@ | ||||
| package org.schabi.newpipe.util; | ||||
|  | ||||
| import static android.content.Context.INPUT_SERVICE; | ||||
|  | ||||
| import android.annotation.SuppressLint; | ||||
| import android.app.UiModeManager; | ||||
| import android.content.Context; | ||||
| import android.content.pm.PackageManager; | ||||
| import android.content.res.Configuration; | ||||
| import android.graphics.Point; | ||||
| import android.hardware.input.InputManager; | ||||
| import android.os.BatteryManager; | ||||
| import android.os.Build; | ||||
| import android.provider.Settings; | ||||
| import android.util.TypedValue; | ||||
| import android.view.InputDevice; | ||||
| import android.view.KeyEvent; | ||||
| import android.view.WindowInsets; | ||||
| import android.view.WindowManager; | ||||
| @@ -22,9 +27,12 @@ import androidx.preference.PreferenceManager; | ||||
| import org.schabi.newpipe.App; | ||||
| import org.schabi.newpipe.R; | ||||
|  | ||||
| import java.lang.reflect.Method; | ||||
|  | ||||
| public final class DeviceUtils { | ||||
|  | ||||
|     private static final String AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv"; | ||||
|     private static final boolean SAMSUNG = Build.MANUFACTURER.equals("samsung"); | ||||
|     private static Boolean isTV = null; | ||||
|     private static Boolean isFireTV = null; | ||||
|  | ||||
| @@ -84,6 +92,82 @@ public final class DeviceUtils { | ||||
|         return DeviceUtils.isTV; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Checks if the device is in desktop or DeX mode. This function should only | ||||
|      * be invoked once on view load as it is using reflection for the DeX checks. | ||||
|      * @param context the context to use for services and config. | ||||
|      * @return true if the Android device is in desktop mode or using DeX. | ||||
|      */ | ||||
|     @SuppressWarnings("JavaReflectionMemberAccess") | ||||
|     public static boolean isDesktopMode(@NonNull final Context context) { | ||||
|         // Adapted from https://stackoverflow.com/a/64615568 | ||||
|         // to check for all input devices that have an active cursor | ||||
|         final InputManager im = (InputManager) context.getSystemService(INPUT_SERVICE); | ||||
|         for (final int id : im.getInputDeviceIds()) { | ||||
|             final InputDevice inputDevice = im.getInputDevice(id); | ||||
|             if (inputDevice.supportsSource(InputDevice.SOURCE_BLUETOOTH_STYLUS) | ||||
|                     || inputDevice.supportsSource(InputDevice.SOURCE_MOUSE) | ||||
|                     || inputDevice.supportsSource(InputDevice.SOURCE_STYLUS) | ||||
|                     || inputDevice.supportsSource(InputDevice.SOURCE_TOUCHPAD) | ||||
|                     || inputDevice.supportsSource(InputDevice.SOURCE_TRACKBALL)) { | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         final UiModeManager uiModeManager = | ||||
|                 ContextCompat.getSystemService(context, UiModeManager.class); | ||||
|         if (uiModeManager != null | ||||
|                 && uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_DESK) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         if (!SAMSUNG) { | ||||
|             return false; | ||||
|             // DeX is Samsung-specific, skip the checks below on non-Samsung devices | ||||
|         } | ||||
|         // DeX check for standalone and multi-window mode, from: | ||||
|         // https://developer.samsung.com/samsung-dex/modify-optimizing.html | ||||
|         try { | ||||
|             final Configuration config = context.getResources().getConfiguration(); | ||||
|             final Class<?> configClass = config.getClass(); | ||||
|             final int semDesktopModeEnabledConst = | ||||
|                     configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass); | ||||
|             final int currentMode = | ||||
|                     configClass.getField("semDesktopModeEnabled").getInt(config); | ||||
|             if (semDesktopModeEnabledConst == currentMode) { | ||||
|                 return true; | ||||
|             } | ||||
|         } catch (final NoSuchFieldException | IllegalAccessException ignored) { | ||||
|             // Device doesn't seem to support DeX | ||||
|         } | ||||
|  | ||||
|         @SuppressLint("WrongConstant") final Object desktopModeManager = context | ||||
|                 .getApplicationContext() | ||||
|                 .getSystemService("desktopmode"); | ||||
|  | ||||
|         if (desktopModeManager != null) { | ||||
|             try { | ||||
|                 final Method getDesktopModeStateMethod = desktopModeManager.getClass() | ||||
|                         .getDeclaredMethod("getDesktopModeState"); | ||||
|                 final Object desktopModeState = getDesktopModeStateMethod | ||||
|                         .invoke(desktopModeManager); | ||||
|                 final Class<?> desktopModeStateClass = desktopModeState.getClass(); | ||||
|                 final Method getEnabledMethod = desktopModeStateClass | ||||
|                         .getDeclaredMethod("getEnabled"); | ||||
|                 final int enabledStatus = (int) getEnabledMethod.invoke(desktopModeState); | ||||
|                 if (enabledStatus == desktopModeStateClass | ||||
|                         .getDeclaredField("ENABLED").getInt(desktopModeStateClass)) { | ||||
|                     return true; | ||||
|                 } | ||||
|             } catch (final Exception ignored) { | ||||
|                 // Device does not support DeX 3.0 or something went wrong when trying to determine | ||||
|                 // if it supports this feature | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     public static boolean isTablet(@NonNull final Context context) { | ||||
|         final String tabletModeSetting = PreferenceManager.getDefaultSharedPreferences(context) | ||||
|                 .getString(context.getString(R.string.tablet_mode_key), ""); | ||||
|   | ||||
| @@ -20,6 +20,7 @@ | ||||
| package org.schabi.newpipe.util; | ||||
|  | ||||
| import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; | ||||
| import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD; | ||||
|  | ||||
| import android.content.Context; | ||||
| import android.util.Log; | ||||
| @@ -51,7 +52,7 @@ import org.schabi.newpipe.extractor.search.SearchInfo; | ||||
| import org.schabi.newpipe.extractor.stream.StreamInfo; | ||||
| import org.schabi.newpipe.extractor.stream.StreamInfoItem; | ||||
| import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor; | ||||
| import org.schabi.newpipe.util.external_communication.TextLinkifier; | ||||
| import org.schabi.newpipe.util.text.TextLinkifier; | ||||
|  | ||||
| import java.util.Collections; | ||||
| import java.util.List; | ||||
| @@ -319,8 +320,9 @@ public final class ExtractorHelper { | ||||
|             } | ||||
|  | ||||
|             metaInfoSeparator.setVisibility(View.VISIBLE); | ||||
|             TextLinkifier.createLinksFromHtmlBlock(metaInfoTextView, stringBuilder.toString(), | ||||
|                     HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, disposables); | ||||
|             TextLinkifier.fromHtml(metaInfoTextView, stringBuilder.toString(), | ||||
|                     HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, null, disposables, | ||||
|                     SET_LINK_MOVEMENT_METHOD); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -56,7 +56,6 @@ import java.util.stream.Collectors; | ||||
|  */ | ||||
|  | ||||
| public final class Localization { | ||||
|  | ||||
|     public static final String DOT_SEPARATOR = " • "; | ||||
|     private static PrettyTime prettyTime; | ||||
|  | ||||
| @@ -76,16 +75,8 @@ public final class Localization { | ||||
|  | ||||
|     public static org.schabi.newpipe.extractor.localization.Localization getPreferredLocalization( | ||||
|             final Context context) { | ||||
|         final String contentLanguage = PreferenceManager | ||||
|                 .getDefaultSharedPreferences(context) | ||||
|                 .getString(context.getString(R.string.content_language_key), | ||||
|                         context.getString(R.string.default_localization_key)); | ||||
|         if (contentLanguage.equals(context.getString(R.string.default_localization_key))) { | ||||
|             return org.schabi.newpipe.extractor.localization.Localization | ||||
|                     .fromLocale(Locale.getDefault()); | ||||
|         } | ||||
|         return org.schabi.newpipe.extractor.localization.Localization | ||||
|                 .fromLocalizationCode(contentLanguage); | ||||
|                 .fromLocale(getPreferredLocale(context)); | ||||
|     } | ||||
|  | ||||
|     public static ContentCountry getPreferredContentCountry(final Context context) { | ||||
| @@ -99,22 +90,11 @@ public final class Localization { | ||||
|     } | ||||
|  | ||||
|     public static Locale getPreferredLocale(final Context context) { | ||||
|         final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); | ||||
|         return getLocaleFromPrefs(context, R.string.content_language_key); | ||||
|     } | ||||
|  | ||||
|         final String languageCode = sp.getString(context.getString(R.string.content_language_key), | ||||
|                 context.getString(R.string.default_localization_key)); | ||||
|  | ||||
|         try { | ||||
|             if (languageCode.length() == 2) { | ||||
|                 return new Locale(languageCode); | ||||
|             } else if (languageCode.contains("_")) { | ||||
|                 final String country = languageCode.substring(languageCode.indexOf("_")); | ||||
|                 return new Locale(languageCode.substring(0, 2), country); | ||||
|             } | ||||
|         } catch (final Exception ignored) { | ||||
|         } | ||||
|  | ||||
|         return Locale.getDefault(); | ||||
|     public static Locale getAppLocale(final Context context) { | ||||
|         return getLocaleFromPrefs(context, R.string.app_language_key); | ||||
|     } | ||||
|  | ||||
|     public static String localizeNumber(final Context context, final long number) { | ||||
| @@ -183,13 +163,13 @@ public final class Localization { | ||||
|  | ||||
|         final double value = (double) count; | ||||
|         if (count >= 1000000000) { | ||||
|             return localizeNumber(context, round(value / 1000000000, 1)) | ||||
|             return localizeNumber(context, round(value / 1000000000)) | ||||
|                     + context.getString(R.string.short_billion); | ||||
|         } else if (count >= 1000000) { | ||||
|             return localizeNumber(context, round(value / 1000000, 1)) | ||||
|             return localizeNumber(context, round(value / 1000000)) | ||||
|                     + context.getString(R.string.short_million); | ||||
|         } else if (count >= 1000) { | ||||
|             return localizeNumber(context, round(value / 1000, 1)) | ||||
|             return localizeNumber(context, round(value / 1000)) | ||||
|                     + context.getString(R.string.short_thousand); | ||||
|         } else { | ||||
|             return localizeNumber(context, value); | ||||
| @@ -226,21 +206,6 @@ public final class Localization { | ||||
|                 deletedCount, shortCount(context, deletedCount)); | ||||
|     } | ||||
|  | ||||
|     private static String getQuantity(final Context context, @PluralsRes final int pluralId, | ||||
|                                       @StringRes final int zeroCaseStringId, final long count, | ||||
|                                       final String formattedCount) { | ||||
|         if (count == 0) { | ||||
|             return context.getString(zeroCaseStringId); | ||||
|         } | ||||
|  | ||||
|         // As we use the already formatted count | ||||
|         // is not the responsibility of this method handle long numbers | ||||
|         // (it probably will fall in the "other" category, | ||||
|         // or some language have some specific rule... then we have to change it) | ||||
|         final int safeCount = (int) MathUtils.clamp(count, Integer.MIN_VALUE, Integer.MAX_VALUE); | ||||
|         return context.getResources().getQuantityString(pluralId, safeCount, formattedCount); | ||||
|     } | ||||
|  | ||||
|     public static String getDurationString(final long duration) { | ||||
|         final String output; | ||||
|  | ||||
| @@ -314,37 +279,42 @@ public final class Localization { | ||||
|         return prettyTime.formatUnrounded(offsetDateTime); | ||||
|     } | ||||
|  | ||||
|     private static void changeAppLanguage(final Locale loc, final Resources res) { | ||||
|     public static void assureCorrectAppLanguage(final Context c) { | ||||
|         final Resources res = c.getResources(); | ||||
|         final DisplayMetrics dm = res.getDisplayMetrics(); | ||||
|         final Configuration conf = res.getConfiguration(); | ||||
|         conf.setLocale(loc); | ||||
|         conf.setLocale(getAppLocale(c)); | ||||
|         res.updateConfiguration(conf, dm); | ||||
|     } | ||||
|  | ||||
|     public static Locale getAppLocale(final Context context) { | ||||
|         final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); | ||||
|         String lang = prefs.getString(context.getString(R.string.app_language_key), "en"); | ||||
|         final Locale loc; | ||||
|         if (lang.equals(context.getString(R.string.default_localization_key))) { | ||||
|             loc = Locale.getDefault(); | ||||
|         } else if (lang.matches(".*-.*")) { | ||||
|             //to differentiate different versions of the language | ||||
|             //for example, pt (portuguese in Portugal) and pt-br (portuguese in Brazil) | ||||
|             final String[] localisation = lang.split("-"); | ||||
|             lang = localisation[0]; | ||||
|             final String country = localisation[1]; | ||||
|             loc = new Locale(lang, country); | ||||
|     private static Locale getLocaleFromPrefs(final Context context, @StringRes final int prefKey) { | ||||
|         final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); | ||||
|         final String defaultKey = context.getString(R.string.default_localization_key); | ||||
|         final String languageCode = sp.getString(context.getString(prefKey), defaultKey); | ||||
|  | ||||
|         if (languageCode.equals(defaultKey)) { | ||||
|             return Locale.getDefault(); | ||||
|         } else { | ||||
|             loc = new Locale(lang); | ||||
|             return Locale.forLanguageTag(languageCode); | ||||
|         } | ||||
|         return loc; | ||||
|     } | ||||
|  | ||||
|     public static void assureCorrectAppLanguage(final Context c) { | ||||
|         changeAppLanguage(getAppLocale(c), c.getResources()); | ||||
|     private static double round(final double value) { | ||||
|         return new BigDecimal(value).setScale(1, RoundingMode.HALF_UP).doubleValue(); | ||||
|     } | ||||
|  | ||||
|     private static double round(final double value, final int places) { | ||||
|         return new BigDecimal(value).setScale(places, RoundingMode.HALF_UP).doubleValue(); | ||||
|     private static String getQuantity(final Context context, @PluralsRes final int pluralId, | ||||
|                                       @StringRes final int zeroCaseStringId, final long count, | ||||
|                                       final String formattedCount) { | ||||
|         if (count == 0) { | ||||
|             return context.getString(zeroCaseStringId); | ||||
|         } | ||||
|  | ||||
|         // As we use the already formatted count | ||||
|         // is not the responsibility of this method handle long numbers | ||||
|         // (it probably will fall in the "other" category, | ||||
|         // or some language have some specific rule... then we have to change it) | ||||
|         final int safeCount = (int) MathUtils.clamp(count, Integer.MIN_VALUE, Integer.MAX_VALUE); | ||||
|         return context.getResources().getQuantityString(pluralId, safeCount, formattedCount); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -156,8 +156,7 @@ public final class NavigationHelper { | ||||
|     public static void playOnPopupPlayer(final Context context, | ||||
|                                          final PlayQueue queue, | ||||
|                                          final boolean resumePlayback) { | ||||
|         if (!PermissionHelper.isPopupEnabled(context)) { | ||||
|             PermissionHelper.showPopupEnablementToast(context); | ||||
|         if (!PermissionHelper.isPopupEnabledElseAsk(context)) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
| @@ -183,6 +182,10 @@ public final class NavigationHelper { | ||||
|     public static void enqueueOnPlayer(final Context context, | ||||
|                                        final PlayQueue queue, | ||||
|                                        final PlayerType playerType) { | ||||
|         if (playerType == PlayerType.POPUP && !PermissionHelper.isPopupEnabledElseAsk(context)) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show(); | ||||
|         final Intent intent = getPlayerEnqueueIntent(context, PlayerService.class, queue); | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,69 @@ | ||||
| package org.schabi.newpipe.util; | ||||
|  | ||||
| import android.app.PendingIntent; | ||||
| import android.content.Context; | ||||
| import android.content.Intent; | ||||
| import android.os.Build; | ||||
|  | ||||
| import androidx.annotation.NonNull; | ||||
|  | ||||
| public final class PendingIntentCompat { | ||||
|     private PendingIntentCompat() { | ||||
|     } | ||||
|  | ||||
|     private static int addImmutableFlag(final int flags) { | ||||
|         return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M | ||||
|                 ? flags | PendingIntent.FLAG_IMMUTABLE : flags; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Creates a {@link PendingIntent} to start an activity. It is immutable on API level 23 and | ||||
|      * greater. | ||||
|      * | ||||
|      * @param context     The context in which the activity should be started. | ||||
|      * @param requestCode The request code | ||||
|      * @param intent      The Intent of the activity to be launched. | ||||
|      * @param flags       The flags for the intent. | ||||
|      * @return The pending intent. | ||||
|      * @see PendingIntent#getActivity(Context, int, Intent, int) | ||||
|      */ | ||||
|     @NonNull | ||||
|     public static PendingIntent getActivity(@NonNull final Context context, final int requestCode, | ||||
|                                             @NonNull final Intent intent, final int flags) { | ||||
|         return PendingIntent.getActivity(context, requestCode, intent, addImmutableFlag(flags)); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Creates a {@link PendingIntent} to start a service. It is immutable on API level 23 and | ||||
|      * greater. | ||||
|      * | ||||
|      * @param context     The context in which the service should be started. | ||||
|      * @param requestCode The request code | ||||
|      * @param intent      The Intent of the service to be launched. | ||||
|      * @param flags       The flags for the intent. | ||||
|      * @return The pending intent. | ||||
|      * @see PendingIntent#getService(Context, int, Intent, int) | ||||
|      */ | ||||
|     @NonNull | ||||
|     public static PendingIntent getService(@NonNull final Context context, final int requestCode, | ||||
|                                            @NonNull final Intent intent, final int flags) { | ||||
|         return PendingIntent.getService(context, requestCode, intent, addImmutableFlag(flags)); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Creates a {@link PendingIntent} to perform a broadcast. It is immutable on API level 23 and | ||||
|      * greater. | ||||
|      * | ||||
|      * @param context     The context in which the broadcast should be performed. | ||||
|      * @param requestCode The request code | ||||
|      * @param intent      The Intent to be broadcast. | ||||
|      * @param flags       The flags for the intent. | ||||
|      * @return The pending intent. | ||||
|      * @see PendingIntent#getBroadcast(Context, int, Intent, int) | ||||
|      */ | ||||
|     @NonNull | ||||
|     public static PendingIntent getBroadcast(@NonNull final Context context, final int requestCode, | ||||
|                                              @NonNull final Intent intent, final int flags) { | ||||
|         return PendingIntent.getBroadcast(context, requestCode, intent, addImmutableFlag(flags)); | ||||
|     } | ||||
| } | ||||
| @@ -9,8 +9,6 @@ import android.content.pm.PackageManager; | ||||
| import android.net.Uri; | ||||
| import android.os.Build; | ||||
| import android.provider.Settings; | ||||
| import android.view.Gravity; | ||||
| import android.widget.TextView; | ||||
| import android.widget.Toast; | ||||
|  | ||||
| import androidx.annotation.RequiresApi; | ||||
| @@ -21,6 +19,7 @@ import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.settings.NewPipeSettings; | ||||
|  | ||||
| public final class PermissionHelper { | ||||
|     public static final int POST_NOTIFICATIONS_REQUEST_CODE = 779; | ||||
|     public static final int DOWNLOAD_DIALOG_REQUEST_CODE = 778; | ||||
|     public static final int DOWNLOADS_REQUEST_CODE = 777; | ||||
|  | ||||
| @@ -71,8 +70,7 @@ public final class PermissionHelper { | ||||
|  | ||||
|             // No explanation needed, we can request the permission. | ||||
|             ActivityCompat.requestPermissions(activity, | ||||
|                     new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, | ||||
|                     requestCode); | ||||
|                     new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, requestCode); | ||||
|  | ||||
|             // PERMISSION_WRITE_STORAGE is an | ||||
|             // app-defined int constant. The callback method gets the | ||||
| @@ -83,6 +81,18 @@ public final class PermissionHelper { | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     public static boolean checkPostNotificationsPermission(final Activity activity, | ||||
|                                                            final int requestCode) { | ||||
|         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU | ||||
|                 && ContextCompat.checkSelfPermission(activity, | ||||
|                 Manifest.permission.POST_NOTIFICATIONS) | ||||
|                 != PackageManager.PERMISSION_GRANTED) { | ||||
|             ActivityCompat.requestPermissions(activity, | ||||
|                     new String[] {Manifest.permission.POST_NOTIFICATIONS}, requestCode); | ||||
|             return false; | ||||
|         } | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * In order to be able to draw over other apps, | ||||
| @@ -116,18 +126,21 @@ public final class PermissionHelper { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static boolean isPopupEnabled(final Context context) { | ||||
|         return Build.VERSION.SDK_INT < Build.VERSION_CODES.M | ||||
|                 || checkSystemAlertWindowPermission(context); | ||||
|     } | ||||
|  | ||||
|     public static void showPopupEnablementToast(final Context context) { | ||||
|         final Toast toast = | ||||
|                 Toast.makeText(context, R.string.msg_popup_permission, Toast.LENGTH_LONG); | ||||
|         final TextView messageView = toast.getView().findViewById(android.R.id.message); | ||||
|         if (messageView != null) { | ||||
|             messageView.setGravity(Gravity.CENTER); | ||||
|     /** | ||||
|      * Determines whether the popup is enabled, and if it is not, starts the system activity to | ||||
|      * request the permission with {@link #checkSystemAlertWindowPermission(Context)} and shows a | ||||
|      * toast to the user explaining why the permission is needed. | ||||
|      * | ||||
|      * @param context the Android context | ||||
|      * @return whether the popup is enabled | ||||
|      */ | ||||
|     public static boolean isPopupEnabledElseAsk(final Context context) { | ||||
|         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M | ||||
|                 || checkSystemAlertWindowPermission(context)) { | ||||
|             return true; | ||||
|         } else { | ||||
|             Toast.makeText(context, R.string.msg_popup_permission, Toast.LENGTH_LONG).show(); | ||||
|             return false; | ||||
|         } | ||||
|         toast.show(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -9,6 +9,7 @@ import android.graphics.Bitmap; | ||||
| import android.util.Log; | ||||
|  | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.core.graphics.BitmapCompat; | ||||
|  | ||||
| import com.squareup.picasso.Cache; | ||||
| import com.squareup.picasso.LruCache; | ||||
| @@ -139,21 +140,23 @@ public final class PicassoHelper { | ||||
|                                         .getDimension(R.dimen.player_notification_thumbnail_width), | ||||
|                                 source.getWidth()); | ||||
|  | ||||
|                         final Bitmap result = Bitmap.createScaledBitmap( | ||||
|                         final Bitmap result = BitmapCompat.createScaledBitmap( | ||||
|                                 source, | ||||
|                                 (int) notificationThumbnailWidth, | ||||
|                                 (int) (source.getHeight() | ||||
|                                         / (source.getWidth() / notificationThumbnailWidth)), | ||||
|                                 null, | ||||
|                                 true); | ||||
|  | ||||
|                         if (result == source) { | ||||
|                         if (result == source || !result.isMutable()) { | ||||
|                             // create a new mutable bitmap to prevent strange crashes on some | ||||
|                             // devices (see #4638) | ||||
|                             final Bitmap copied = Bitmap.createScaledBitmap( | ||||
|                             final Bitmap copied = BitmapCompat.createScaledBitmap( | ||||
|                                     source, | ||||
|                                     (int) notificationThumbnailWidth - 1, | ||||
|                                     (int) (source.getHeight() / (source.getWidth() | ||||
|                                             / (notificationThumbnailWidth - 1))), | ||||
|                                     null, | ||||
|                                     true); | ||||
|                             source.recycle(); | ||||
|                             return copied; | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| package org.schabi.newpipe.util; | ||||
|  | ||||
| import android.content.Context; | ||||
| import android.util.SparseArray; | ||||
| import android.view.LayoutInflater; | ||||
| import android.view.View; | ||||
| import android.view.ViewGroup; | ||||
| @@ -11,6 +10,8 @@ import android.widget.Spinner; | ||||
| import android.widget.TextView; | ||||
|  | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.collection.SparseArrayCompat; | ||||
|  | ||||
| import org.schabi.newpipe.DownloaderImpl; | ||||
| import org.schabi.newpipe.R; | ||||
| @@ -39,10 +40,10 @@ import us.shandian.giga.util.Utility; | ||||
|  * @param <U> the secondary stream type's class extending {@link Stream} | ||||
|  */ | ||||
| public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseAdapter { | ||||
|     private final Context context; | ||||
|  | ||||
|     @NonNull | ||||
|     private final StreamSizeWrapper<T> streamsWrapper; | ||||
|     private final SparseArray<SecondaryStreamHelper<U>> secondaryStreams; | ||||
|     @NonNull | ||||
|     private final SparseArrayCompat<SecondaryStreamHelper<U>> secondaryStreams; | ||||
|  | ||||
|     /** | ||||
|      * Indicates that at least one of the primary streams is an instance of {@link VideoStream}, | ||||
| @@ -51,9 +52,10 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA | ||||
|      */ | ||||
|     private final boolean hasAnyVideoOnlyStreamWithNoSecondaryStream; | ||||
|  | ||||
|     public StreamItemAdapter(final Context context, final StreamSizeWrapper<T> streamsWrapper, | ||||
|                              final SparseArray<SecondaryStreamHelper<U>> secondaryStreams) { | ||||
|         this.context = context; | ||||
|     public StreamItemAdapter( | ||||
|             @NonNull final StreamSizeWrapper<T> streamsWrapper, | ||||
|             @NonNull final SparseArrayCompat<SecondaryStreamHelper<U>> secondaryStreams | ||||
|     ) { | ||||
|         this.streamsWrapper = streamsWrapper; | ||||
|         this.secondaryStreams = secondaryStreams; | ||||
|  | ||||
| @@ -61,15 +63,15 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA | ||||
|                 checkHasAnyVideoOnlyStreamWithNoSecondaryStream(); | ||||
|     } | ||||
|  | ||||
|     public StreamItemAdapter(final Context context, final StreamSizeWrapper<T> streamsWrapper) { | ||||
|         this(context, streamsWrapper, null); | ||||
|     public StreamItemAdapter(final StreamSizeWrapper<T> streamsWrapper) { | ||||
|         this(streamsWrapper, new SparseArrayCompat<>(0)); | ||||
|     } | ||||
|  | ||||
|     public List<T> getAll() { | ||||
|         return streamsWrapper.getStreamsList(); | ||||
|     } | ||||
|  | ||||
|     public SparseArray<SecondaryStreamHelper<U>> getAllSecondary() { | ||||
|     public SparseArrayCompat<SecondaryStreamHelper<U>> getAllSecondary() { | ||||
|         return secondaryStreams; | ||||
|     } | ||||
|  | ||||
| @@ -106,6 +108,7 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA | ||||
|                                final View view, | ||||
|                                final ViewGroup parent, | ||||
|                                final boolean isDropdownItem) { | ||||
|         final var context = parent.getContext(); | ||||
|         View convertView = view; | ||||
|         if (convertView == null) { | ||||
|             convertView = LayoutInflater.from(context).inflate( | ||||
| @@ -129,7 +132,7 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA | ||||
|  | ||||
|             if (hasAnyVideoOnlyStreamWithNoSecondaryStream) { | ||||
|                 if (videoStream.isVideoOnly()) { | ||||
|                     woSoundIconVisibility = hasSecondaryStream(position) | ||||
|                     woSoundIconVisibility = secondaryStreams.get(position) != null | ||||
|                             // It has a secondary stream associated with it, so check if it's a | ||||
|                             // dropdown view so it doesn't look out of place (missing margin) | ||||
|                             // compared to those that don't. | ||||
| @@ -163,8 +166,7 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA | ||||
|         } | ||||
|  | ||||
|         if (streamsWrapper.getSizeInBytes(position) > 0) { | ||||
|             final SecondaryStreamHelper<U> secondary = secondaryStreams == null ? null | ||||
|                     : secondaryStreams.get(position); | ||||
|             final var secondary = secondaryStreams.get(position); | ||||
|             if (secondary != null) { | ||||
|                 final long size = secondary.getSizeInBytes() | ||||
|                         + streamsWrapper.getSizeInBytes(position); | ||||
| @@ -196,14 +198,6 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA | ||||
|         return convertView; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param position which primary stream to check. | ||||
|      * @return whether the primary stream at position has a secondary stream associated with it. | ||||
|      */ | ||||
|     private boolean hasSecondaryStream(final int position) { | ||||
|         return secondaryStreams != null && secondaryStreams.get(position) != null; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return if there are any video-only streams with no secondary stream associated with them. | ||||
|      * @see #hasAnyVideoOnlyStreamWithNoSecondaryStream | ||||
| @@ -213,7 +207,7 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA | ||||
|             final T stream = streamsWrapper.getStreamsList().get(i); | ||||
|             if (stream instanceof VideoStream) { | ||||
|                 final boolean videoOnly = ((VideoStream) stream).isVideoOnly(); | ||||
|                 if (videoOnly && !hasSecondaryStream(i)) { | ||||
|                 if (videoOnly && secondaryStreams.get(i) == null) { | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
| @@ -228,16 +222,15 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA | ||||
|      * @param <T> the stream type's class extending {@link Stream} | ||||
|      */ | ||||
|     public static class StreamSizeWrapper<T extends Stream> implements Serializable { | ||||
|         private static final StreamSizeWrapper<Stream> EMPTY = new StreamSizeWrapper<>( | ||||
|                 Collections.emptyList(), null); | ||||
|         private static final StreamSizeWrapper<Stream> EMPTY = | ||||
|                 new StreamSizeWrapper<>(Collections.emptyList(), null); | ||||
|         private final List<T> streamsList; | ||||
|         private final long[] streamSizes; | ||||
|         private final String unknownSize; | ||||
|  | ||||
|         public StreamSizeWrapper(final List<T> sL, final Context context) { | ||||
|             this.streamsList = sL != null | ||||
|                     ? sL | ||||
|                     : Collections.emptyList(); | ||||
|         public StreamSizeWrapper(@NonNull final List<T> streamList, | ||||
|                                  @Nullable final Context context) { | ||||
|             this.streamsList = streamList; | ||||
|             this.streamSizes = new long[streamsList.size()]; | ||||
|             this.unknownSize = context == null | ||||
|                     ? "--.-" : context.getString(R.string.unknown_content); | ||||
| @@ -297,10 +290,6 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA | ||||
|             return formatSize(getSizeInBytes(streamIndex)); | ||||
|         } | ||||
|  | ||||
|         public String getFormattedSize(final T stream) { | ||||
|             return formatSize(getSizeInBytes(stream)); | ||||
|         } | ||||
|  | ||||
|         private String formatSize(final long size) { | ||||
|             if (size > -1) { | ||||
|                 return Utility.formatBytes(size); | ||||
| @@ -308,10 +297,6 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA | ||||
|             return unknownSize; | ||||
|         } | ||||
|  | ||||
|         public void setSize(final int streamIndex, final long sizeInBytes) { | ||||
|             streamSizes[streamIndex] = sizeInBytes; | ||||
|         } | ||||
|  | ||||
|         public void setSize(final T stream, final long sizeInBytes) { | ||||
|             streamSizes[streamsList.indexOf(stream)] = sizeInBytes; | ||||
|         } | ||||
|   | ||||
| @@ -41,6 +41,7 @@ import org.schabi.newpipe.R; | ||||
| import org.schabi.newpipe.extractor.NewPipe; | ||||
| import org.schabi.newpipe.extractor.StreamingService; | ||||
| import org.schabi.newpipe.extractor.exceptions.ExtractionException; | ||||
| import org.schabi.newpipe.info_list.ItemViewMode; | ||||
|  | ||||
| public final class ThemeHelper { | ||||
|     private ThemeHelper() { | ||||
| @@ -332,7 +333,6 @@ public final class ThemeHelper { | ||||
|         } | ||||
|     } | ||||
|  | ||||
|  | ||||
|     /** | ||||
|      * Returns whether the grid layout or the list layout should be used. If the user set "auto" | ||||
|      * mode in settings, decides based on screen orientation (landscape) and size. | ||||
| @@ -341,19 +341,8 @@ public final class ThemeHelper { | ||||
|      * @return true:use grid layout, false:use list layout | ||||
|      */ | ||||
|     public static boolean shouldUseGridLayout(final Context context) { | ||||
|         final String listMode = PreferenceManager.getDefaultSharedPreferences(context) | ||||
|                 .getString(context.getString(R.string.list_view_mode_key), | ||||
|                         context.getString(R.string.list_view_mode_value)); | ||||
|  | ||||
|         if (listMode.equals(context.getString(R.string.list_view_mode_list_key))) { | ||||
|             return false; | ||||
|         } else if (listMode.equals(context.getString(R.string.list_view_mode_grid_key))) { | ||||
|             return true; | ||||
|         } else /* listMode.equals("auto") */ { | ||||
|             final Configuration configuration = context.getResources().getConfiguration(); | ||||
|             return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE | ||||
|                     && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE); | ||||
|         } | ||||
|         final ItemViewMode mode = getItemViewMode(context); | ||||
|         return mode == ItemViewMode.GRID; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -367,6 +356,36 @@ public final class ThemeHelper { | ||||
|                 context.getResources().getDimensionPixelSize(R.dimen.channel_item_grid_min_width)); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Returns item view mode. | ||||
|      * @param context to read preference and parse string | ||||
|      * @return Returns one of ItemViewMode | ||||
|      */ | ||||
|     public static ItemViewMode getItemViewMode(final Context context) { | ||||
|         final String listMode = PreferenceManager.getDefaultSharedPreferences(context) | ||||
|                 .getString(context.getString(R.string.list_view_mode_key), | ||||
|                         context.getString(R.string.list_view_mode_value)); | ||||
|         final ItemViewMode result; | ||||
|         if (listMode.equals(context.getString(R.string.list_view_mode_list_key))) { | ||||
|             result = ItemViewMode.LIST; | ||||
|         } else if (listMode.equals(context.getString(R.string.list_view_mode_grid_key))) { | ||||
|             result = ItemViewMode.GRID; | ||||
|         } else if (listMode.equals(context.getString(R.string.list_view_mode_card_key))) { | ||||
|             result = ItemViewMode.CARD; | ||||
|         } else { | ||||
|             // Auto mode - evaluate whether to use Grid based on screen real estate. | ||||
|             final Configuration configuration = context.getResources().getConfiguration(); | ||||
|             final boolean useGrid = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE | ||||
|                     && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE); | ||||
|             if (useGrid) { | ||||
|                 result = ItemViewMode.GRID; | ||||
|             } else { | ||||
|                 result = ItemViewMode.LIST; | ||||
|             } | ||||
|         } | ||||
|         return result; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Calculates the number of grid stream info items that can fit horizontally on the screen. The | ||||
|      * width of a grid stream info item is obtained from the thumbnail width plus the right and left | ||||
|   | ||||
| @@ -90,19 +90,16 @@ public final class ShareUtils { | ||||
|             // No browser set as default (doesn't work on some devices) | ||||
|             openAppChooser(context, intent, true); | ||||
|         } else { | ||||
|             if (defaultPackageName.isEmpty()) { | ||||
|                 // No app installed to open a web url | ||||
|                 Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show(); | ||||
|                 return false; | ||||
|             } else { | ||||
|                 try { | ||||
|             try { | ||||
|                 // will be empty on Android 12+ | ||||
|                 if (!defaultPackageName.isEmpty()) { | ||||
|                     intent.setPackage(defaultPackageName); | ||||
|                     context.startActivity(intent); | ||||
|                 } catch (final ActivityNotFoundException e) { | ||||
|                     // Not a browser but an app chooser because of OEMs changes | ||||
|                     intent.setPackage(null); | ||||
|                     openAppChooser(context, intent, true); | ||||
|                 } | ||||
|                 context.startActivity(intent); | ||||
|             } catch (final ActivityNotFoundException e) { | ||||
|                 // Not a browser but an app chooser because of OEMs changes | ||||
|                 intent.setPackage(null); | ||||
|                 openAppChooser(context, intent, true); | ||||
|             } | ||||
|         } | ||||
|  | ||||
| @@ -313,8 +310,16 @@ public final class ShareUtils { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); | ||||
|         Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); | ||||
|         try { | ||||
|             clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); | ||||
|             if (Build.VERSION.SDK_INT < 33) { | ||||
|                 // Android 13 has its own "copied to clipboard" dialog | ||||
|                 Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); | ||||
|             } | ||||
|         } catch (final Exception e) { | ||||
|             Log.e(TAG, "Error when trying to copy text to clipboard", e); | ||||
|             Toast.makeText(context, R.string.msg_failed_to_copy, Toast.LENGTH_SHORT).show(); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -1,289 +0,0 @@ | ||||
| package org.schabi.newpipe.util.external_communication; | ||||
|  | ||||
| import android.content.Context; | ||||
| import android.text.SpannableStringBuilder; | ||||
| import android.text.method.LinkMovementMethod; | ||||
| import android.text.style.ClickableSpan; | ||||
| import android.text.style.URLSpan; | ||||
| import android.text.util.Linkify; | ||||
| import android.util.Log; | ||||
| import android.view.View; | ||||
| import android.widget.TextView; | ||||
|  | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.core.text.HtmlCompat; | ||||
|  | ||||
| import org.schabi.newpipe.extractor.Info; | ||||
| import org.schabi.newpipe.extractor.stream.StreamInfo; | ||||
| import org.schabi.newpipe.util.NavigationHelper; | ||||
|  | ||||
| import java.util.regex.Matcher; | ||||
| import java.util.regex.Pattern; | ||||
|  | ||||
| import io.noties.markwon.Markwon; | ||||
| import io.noties.markwon.linkify.LinkifyPlugin; | ||||
| import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; | ||||
| import io.reactivex.rxjava3.core.Single; | ||||
| import io.reactivex.rxjava3.disposables.CompositeDisposable; | ||||
| import io.reactivex.rxjava3.schedulers.Schedulers; | ||||
|  | ||||
| import static org.schabi.newpipe.util.external_communication.InternalUrlsHandler.playOnPopup; | ||||
|  | ||||
| public final class TextLinkifier { | ||||
|     public static final String TAG = TextLinkifier.class.getSimpleName(); | ||||
|  | ||||
|     // Looks for hashtags with characters from any language (\p{L}), numbers, or underscores | ||||
|     private static final Pattern HASHTAGS_PATTERN = | ||||
|             Pattern.compile("(#[\\p{L}0-9_]+)"); | ||||
|  | ||||
|     private TextLinkifier() { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create web links for contents with an HTML description. | ||||
|      * <p> | ||||
|      * This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence, | ||||
|      * Info, CompositeDisposable)} after having linked the URLs with | ||||
|      * {@link HtmlCompat#fromHtml(String, int)}. | ||||
|      * | ||||
|      * @param textView       the TextView to set the htmlBlock linked | ||||
|      * @param htmlBlock      the htmlBlock to be linked | ||||
|      * @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, int)} | ||||
|      *                       will be called | ||||
|      * @param relatedInfo    if given, handle timestamps to open the stream in the popup player at | ||||
|      *                       the specific time, and hashtags to search for the term in the correct | ||||
|      *                       service | ||||
|      * @param disposables    disposables created by the method are added here and their lifecycle | ||||
|      *                       should be handled by the calling class | ||||
|      */ | ||||
|     public static void createLinksFromHtmlBlock(@NonNull final TextView textView, | ||||
|                                                 final String htmlBlock, | ||||
|                                                 final int htmlCompatFlag, | ||||
|                                                 @Nullable final Info relatedInfo, | ||||
|                                                 final CompositeDisposable disposables) { | ||||
|         changeIntentsOfDescriptionLinks( | ||||
|                 textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfo, disposables); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create web links for contents with a plain text description. | ||||
|      * <p> | ||||
|      * This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence, | ||||
|      * Info, CompositeDisposable)} after having linked the URLs with | ||||
|      * {@link TextView#setAutoLinkMask(int)} and | ||||
|      * {@link TextView#setText(CharSequence, TextView.BufferType)}. | ||||
|      * | ||||
|      * @param textView       the TextView to set the plain text block linked | ||||
|      * @param plainTextBlock the block of plain text to be linked | ||||
|      * @param relatedInfo    if given, handle timestamps to open the stream in the popup player at | ||||
|      *                       the specific time, and hashtags to search for the term in the correct | ||||
|      *                       service | ||||
|      * @param disposables    disposables created by the method are added here and their lifecycle | ||||
|      *                       should be handled by the calling class | ||||
|      */ | ||||
|     public static void createLinksFromPlainText(@NonNull final TextView textView, | ||||
|                                                 final String plainTextBlock, | ||||
|                                                 @Nullable final Info relatedInfo, | ||||
|                                                 final CompositeDisposable disposables) { | ||||
|         textView.setAutoLinkMask(Linkify.WEB_URLS); | ||||
|         textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE); | ||||
|         changeIntentsOfDescriptionLinks(textView, textView.getText(), relatedInfo, disposables); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create web links for contents with a markdown description. | ||||
|      * <p> | ||||
|      * This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence, | ||||
|      * Info, CompositeDisposable)} after creating an {@link Markwon} object and using | ||||
|      * {@link Markwon#setMarkdown(TextView, String)}. | ||||
|      * | ||||
|      * @param textView      the TextView to set the plain text block linked | ||||
|      * @param markdownBlock the block of markdown text to be linked | ||||
|      * @param relatedInfo   if given, handle timestamps to open the stream in the popup player at | ||||
|      *                      the specific time, and hashtags to search for the term in the correct | ||||
|      * @param disposables   disposables created by the method are added here and their lifecycle | ||||
|      *                      should be handled by the calling class | ||||
|      */ | ||||
|     public static void createLinksFromMarkdownText(@NonNull final TextView textView, | ||||
|                                                    final String markdownBlock, | ||||
|                                                    @Nullable final Info relatedInfo, | ||||
|                                                    final CompositeDisposable disposables) { | ||||
|         final Markwon markwon = Markwon.builder(textView.getContext()) | ||||
|                 .usePlugin(LinkifyPlugin.create()).build(); | ||||
|         changeIntentsOfDescriptionLinks(textView, markwon.toMarkdown(markdownBlock), relatedInfo, | ||||
|                 disposables); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add click listeners which opens a search on hashtags in a plain text. | ||||
|      * <p> | ||||
|      * This method finds all timestamps in the {@link SpannableStringBuilder} of the description | ||||
|      * using a regular expression, adds for each a {@link ClickableSpan} which opens | ||||
|      * {@link NavigationHelper#openSearch(Context, int, String)} and makes a search on the hashtag, | ||||
|      * in the service of the content. | ||||
|      * | ||||
|      * @param context              the context to use | ||||
|      * @param spannableDescription the SpannableStringBuilder with the text of the | ||||
|      *                             content description | ||||
|      * @param relatedInfo          used to search for the term in the correct service | ||||
|      */ | ||||
|     private static void addClickListenersOnHashtags(final Context context, | ||||
|                                                     @NonNull final SpannableStringBuilder | ||||
|                                                             spannableDescription, | ||||
|                                                     final Info relatedInfo) { | ||||
|         final String descriptionText = spannableDescription.toString(); | ||||
|         final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText); | ||||
|  | ||||
|         while (hashtagsMatches.find()) { | ||||
|             final int hashtagStart = hashtagsMatches.start(1); | ||||
|             final int hashtagEnd = hashtagsMatches.end(1); | ||||
|             final String parsedHashtag = descriptionText.substring(hashtagStart, hashtagEnd); | ||||
|  | ||||
|             // don't add a ClickableSpan if there is already one, which should be a part of an URL, | ||||
|             // already parsed before | ||||
|             if (spannableDescription.getSpans(hashtagStart, hashtagEnd, | ||||
|                     ClickableSpan.class).length == 0) { | ||||
|                 spannableDescription.setSpan(new ClickableSpan() { | ||||
|                     @Override | ||||
|                     public void onClick(@NonNull final View view) { | ||||
|                         NavigationHelper.openSearch(context, relatedInfo.getServiceId(), | ||||
|                                 parsedHashtag); | ||||
|                     } | ||||
|                 }, hashtagStart, hashtagEnd, 0); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add click listeners which opens the popup player on timestamps in a plain text. | ||||
|      * <p> | ||||
|      * This method finds all timestamps in the {@link SpannableStringBuilder} of the description | ||||
|      * using a regular expression, adds for each a {@link ClickableSpan} which opens the popup | ||||
|      * player at the time indicated in the timestamps. | ||||
|      * | ||||
|      * @param context              the context to use | ||||
|      * @param spannableDescription the SpannableStringBuilder with the text of the | ||||
|      *                             content description | ||||
|      * @param relatedInfo          what to open in the popup player when timestamps are clicked | ||||
|      * @param disposables          disposables created by the method are added here and their | ||||
|      *                             lifecycle should be handled by the calling class | ||||
|      */ | ||||
|     private static void addClickListenersOnTimestamps(final Context context, | ||||
|                                                       @NonNull final SpannableStringBuilder | ||||
|                                                               spannableDescription, | ||||
|                                                       final Info relatedInfo, | ||||
|                                                       final CompositeDisposable disposables) { | ||||
|         final String descriptionText = spannableDescription.toString(); | ||||
|         final Matcher timestampsMatches = | ||||
|                 TimestampExtractor.TIMESTAMPS_PATTERN.matcher(descriptionText); | ||||
|  | ||||
|         while (timestampsMatches.find()) { | ||||
|             final TimestampExtractor.TimestampMatchDTO timestampMatchDTO = | ||||
|                     TimestampExtractor.getTimestampFromMatcher( | ||||
|                             timestampsMatches, | ||||
|                             descriptionText); | ||||
|  | ||||
|             if (timestampMatchDTO == null) { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             spannableDescription.setSpan( | ||||
|                     new ClickableSpan() { | ||||
|                         @Override | ||||
|                         public void onClick(@NonNull final View view) { | ||||
|                             playOnPopup( | ||||
|                                     context, | ||||
|                                     relatedInfo.getUrl(), | ||||
|                                     relatedInfo.getService(), | ||||
|                                     timestampMatchDTO.seconds(), | ||||
|                                     disposables); | ||||
|                         } | ||||
|                     }, | ||||
|                     timestampMatchDTO.timestampStart(), | ||||
|                     timestampMatchDTO.timestampEnd(), | ||||
|                     0); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Change links generated by libraries in the description of a content to a custom link action | ||||
|      * and add click listeners on timestamps in this description. | ||||
|      * <p> | ||||
|      * Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of | ||||
|      * a content, this method will parse the {@link CharSequence} and replace all current web links | ||||
|      * with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}. | ||||
|      * This method will also add click listeners on timestamps in this description, which will play | ||||
|      * the content in the popup player at the time indicated in the timestamp, by using | ||||
|      * {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder, Info, | ||||
|      * CompositeDisposable)} method and click listeners on hashtags, by using | ||||
|      * {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder, Info)}, | ||||
|      * which will open a search on the current service with the hashtag. | ||||
|      * <p> | ||||
|      * This method is required in order to intercept links and e.g. show a confirmation dialog | ||||
|      * before opening a web link. | ||||
|      * | ||||
|      * @param textView    the TextView in which the converted CharSequence will be applied | ||||
|      * @param chars       the CharSequence to be parsed | ||||
|      * @param relatedInfo if given, handle timestamps to open the stream in the popup player at | ||||
|      *                    the specific time, and hashtags to search for the term in the correct | ||||
|      *                    service | ||||
|      * @param disposables disposables created by the method are added here and their lifecycle | ||||
|      *                    should be handled by the calling class | ||||
|      */ | ||||
|     private static void changeIntentsOfDescriptionLinks(final TextView textView, | ||||
|                                                         final CharSequence chars, | ||||
|                                                         @Nullable final Info relatedInfo, | ||||
|                                                         final CompositeDisposable disposables) { | ||||
|         disposables.add(Single.fromCallable(() -> { | ||||
|             final Context context = textView.getContext(); | ||||
|  | ||||
|             // add custom click actions on web links | ||||
|             final SpannableStringBuilder textBlockLinked = new SpannableStringBuilder(chars); | ||||
|             final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), URLSpan.class); | ||||
|  | ||||
|             for (final URLSpan span : urls) { | ||||
|                 final String url = span.getURL(); | ||||
|                 final ClickableSpan clickableSpan = new ClickableSpan() { | ||||
|                     public void onClick(@NonNull final View view) { | ||||
|                         if (!InternalUrlsHandler.handleUrlDescriptionTimestamp( | ||||
|                                 new CompositeDisposable(), context, url)) { | ||||
|                             ShareUtils.openUrlInBrowser(context, url, false); | ||||
|                         } | ||||
|                     } | ||||
|                 }; | ||||
|  | ||||
|                 textBlockLinked.setSpan(clickableSpan, textBlockLinked.getSpanStart(span), | ||||
|                         textBlockLinked.getSpanEnd(span), textBlockLinked.getSpanFlags(span)); | ||||
|                 textBlockLinked.removeSpan(span); | ||||
|             } | ||||
|  | ||||
|             // add click actions on plain text timestamps only for description of contents, | ||||
|             // unneeded for meta-info or other TextViews | ||||
|             if (relatedInfo != null) { | ||||
|                 if (relatedInfo instanceof StreamInfo) { | ||||
|                     addClickListenersOnTimestamps(context, textBlockLinked, relatedInfo, | ||||
|                             disposables); | ||||
|                 } | ||||
|                 addClickListenersOnHashtags(context, textBlockLinked, relatedInfo); | ||||
|             } | ||||
|  | ||||
|             return textBlockLinked; | ||||
|         }).subscribeOn(Schedulers.computation()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribe( | ||||
|                         textBlockLinked -> setTextViewCharSequence(textView, textBlockLinked), | ||||
|                         throwable -> { | ||||
|                             Log.e(TAG, "Unable to linkify text", throwable); | ||||
|                             // this should never happen, but if it does, just fallback to it | ||||
|                             setTextViewCharSequence(textView, chars); | ||||
|                         })); | ||||
|     } | ||||
|  | ||||
|     private static void setTextViewCharSequence(@NonNull final TextView textView, | ||||
|                                                 final CharSequence charSequence) { | ||||
|         textView.setText(charSequence); | ||||
|         textView.setMovementMethod(LinkMovementMethod.getInstance()); | ||||
|         textView.setVisibility(View.VISIBLE); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,42 @@ | ||||
| package org.schabi.newpipe.util.text; | ||||
|  | ||||
| import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine; | ||||
|  | ||||
| import android.annotation.SuppressLint; | ||||
| import android.text.Spanned; | ||||
| import android.text.style.ClickableSpan; | ||||
| import android.view.MotionEvent; | ||||
| import android.view.View; | ||||
| import android.widget.TextView; | ||||
|  | ||||
| public class CommentTextOnTouchListener implements View.OnTouchListener { | ||||
|     public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener(); | ||||
|  | ||||
|     @SuppressLint("ClickableViewAccessibility") | ||||
|     @Override | ||||
|     public boolean onTouch(final View v, final MotionEvent event) { | ||||
|         if (!(v instanceof TextView)) { | ||||
|             return false; | ||||
|         } | ||||
|         final TextView widget = (TextView) v; | ||||
|         final CharSequence text = widget.getText(); | ||||
|         if (text instanceof Spanned) { | ||||
|             final Spanned buffer = (Spanned) text; | ||||
|             final int action = event.getAction(); | ||||
|  | ||||
|             if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { | ||||
|                 final int offset = getOffsetForHorizontalLine(widget, event); | ||||
|                 final ClickableSpan[] links = buffer.getSpans(offset, offset, ClickableSpan.class); | ||||
|  | ||||
|                 if (links.length != 0) { | ||||
|                     if (action == MotionEvent.ACTION_UP) { | ||||
|                         links[0].onClick(widget); | ||||
|                     } | ||||
|                     // we handle events that intersect links, so return true | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         return false; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,36 @@ | ||||
| package org.schabi.newpipe.util.text; | ||||
|  | ||||
| import android.content.Context; | ||||
| import android.view.View; | ||||
|  | ||||
| import androidx.annotation.NonNull; | ||||
|  | ||||
| import org.schabi.newpipe.util.NavigationHelper; | ||||
| import org.schabi.newpipe.util.external_communication.ShareUtils; | ||||
|  | ||||
| final class HashtagLongPressClickableSpan extends LongPressClickableSpan { | ||||
|  | ||||
|     @NonNull | ||||
|     private final Context context; | ||||
|     @NonNull | ||||
|     private final String parsedHashtag; | ||||
|     private final int relatedInfoServiceId; | ||||
|  | ||||
|     HashtagLongPressClickableSpan(@NonNull final Context context, | ||||
|                                   @NonNull final String parsedHashtag, | ||||
|                                   final int relatedInfoServiceId) { | ||||
|         this.context = context; | ||||
|         this.parsedHashtag = parsedHashtag; | ||||
|         this.relatedInfoServiceId = relatedInfoServiceId; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void onClick(@NonNull final View view) { | ||||
|         NavigationHelper.openSearch(context, relatedInfoServiceId, parsedHashtag); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void onLongClick(@NonNull final View view) { | ||||
|         ShareUtils.copyToClipboard(context, parsedHashtag); | ||||
|     } | ||||
| } | ||||
| @@ -1,4 +1,4 @@ | ||||
| package org.schabi.newpipe.util.external_communication; | ||||
| package org.schabi.newpipe.util.text; | ||||
| 
 | ||||
| import android.content.Context; | ||||
| import android.util.Log; | ||||
| @@ -0,0 +1,12 @@ | ||||
| package org.schabi.newpipe.util.text; | ||||
|  | ||||
| import android.text.style.ClickableSpan; | ||||
| import android.view.View; | ||||
|  | ||||
| import androidx.annotation.NonNull; | ||||
|  | ||||
| public abstract class LongPressClickableSpan extends ClickableSpan { | ||||
|  | ||||
|     public abstract void onLongClick(@NonNull View view); | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,77 @@ | ||||
| package org.schabi.newpipe.util.text; | ||||
|  | ||||
| import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine; | ||||
|  | ||||
| import android.os.Handler; | ||||
| import android.os.Looper; | ||||
| import android.text.Selection; | ||||
| import android.text.Spannable; | ||||
| import android.text.method.LinkMovementMethod; | ||||
| import android.text.method.MovementMethod; | ||||
| import android.view.MotionEvent; | ||||
| import android.view.ViewConfiguration; | ||||
| import android.widget.TextView; | ||||
|  | ||||
| import androidx.annotation.NonNull; | ||||
|  | ||||
| // Class adapted from https://stackoverflow.com/a/31786969 | ||||
|  | ||||
| public class LongPressLinkMovementMethod extends LinkMovementMethod { | ||||
|  | ||||
|     private static final int LONG_PRESS_TIME = ViewConfiguration.getLongPressTimeout(); | ||||
|  | ||||
|     private static LongPressLinkMovementMethod instance; | ||||
|  | ||||
|     private Handler longClickHandler; | ||||
|     private boolean isLongPressed = false; | ||||
|  | ||||
|     @Override | ||||
|     public boolean onTouchEvent(@NonNull final TextView widget, | ||||
|                                 @NonNull final Spannable buffer, | ||||
|                                 @NonNull final MotionEvent event) { | ||||
|         final int action = event.getAction(); | ||||
|  | ||||
|         if (action == MotionEvent.ACTION_CANCEL && longClickHandler != null) { | ||||
|             longClickHandler.removeCallbacksAndMessages(null); | ||||
|         } | ||||
|  | ||||
|         if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { | ||||
|             final int offset = getOffsetForHorizontalLine(widget, event); | ||||
|             final LongPressClickableSpan[] link = buffer.getSpans(offset, offset, | ||||
|                     LongPressClickableSpan.class); | ||||
|  | ||||
|             if (link.length != 0) { | ||||
|                 if (action == MotionEvent.ACTION_UP) { | ||||
|                     if (longClickHandler != null) { | ||||
|                         longClickHandler.removeCallbacksAndMessages(null); | ||||
|                     } | ||||
|                     if (!isLongPressed) { | ||||
|                         link[0].onClick(widget); | ||||
|                     } | ||||
|                     isLongPressed = false; | ||||
|                 } else { | ||||
|                     Selection.setSelection(buffer, buffer.getSpanStart(link[0]), | ||||
|                             buffer.getSpanEnd(link[0])); | ||||
|                     if (longClickHandler != null) { | ||||
|                         longClickHandler.postDelayed(() -> { | ||||
|                             link[0].onLongClick(widget); | ||||
|                             isLongPressed = true; | ||||
|                         }, LONG_PRESS_TIME); | ||||
|                     } | ||||
|                 } | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return super.onTouchEvent(widget, buffer, event); | ||||
|     } | ||||
|  | ||||
|     public static MovementMethod getInstance() { | ||||
|         if (instance == null) { | ||||
|             instance = new LongPressLinkMovementMethod(); | ||||
|             instance.longClickHandler = new Handler(Looper.myLooper()); | ||||
|         } | ||||
|  | ||||
|         return instance; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,369 @@ | ||||
| package org.schabi.newpipe.util.text; | ||||
|  | ||||
| import android.content.Context; | ||||
| import android.text.SpannableStringBuilder; | ||||
| import android.text.style.URLSpan; | ||||
| import android.text.util.Linkify; | ||||
| import android.util.Log; | ||||
| import android.view.View; | ||||
| import android.widget.TextView; | ||||
|  | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| import androidx.core.text.HtmlCompat; | ||||
|  | ||||
| import org.schabi.newpipe.extractor.StreamingService; | ||||
| import org.schabi.newpipe.extractor.stream.Description; | ||||
| import org.schabi.newpipe.util.NavigationHelper; | ||||
| import org.schabi.newpipe.util.external_communication.ShareUtils; | ||||
|  | ||||
| import java.util.function.Consumer; | ||||
| import java.util.regex.Matcher; | ||||
| import java.util.regex.Pattern; | ||||
|  | ||||
| import io.noties.markwon.Markwon; | ||||
| import io.noties.markwon.linkify.LinkifyPlugin; | ||||
| import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; | ||||
| import io.reactivex.rxjava3.core.Single; | ||||
| import io.reactivex.rxjava3.disposables.CompositeDisposable; | ||||
| import io.reactivex.rxjava3.schedulers.Schedulers; | ||||
|  | ||||
| public final class TextLinkifier { | ||||
|     public static final String TAG = TextLinkifier.class.getSimpleName(); | ||||
|  | ||||
|     // Looks for hashtags with characters from any language (\p{L}), numbers, or underscores | ||||
|     private static final Pattern HASHTAGS_PATTERN = Pattern.compile("(#[\\p{L}0-9_]+)"); | ||||
|  | ||||
|     public static final Consumer<TextView> SET_LINK_MOVEMENT_METHOD = | ||||
|             v -> v.setMovementMethod(LongPressLinkMovementMethod.getInstance()); | ||||
|  | ||||
|     private TextLinkifier() { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create links for contents with an {@link Description} in the various possible formats. | ||||
|      * <p> | ||||
|      * This will call one of these three functions based on the format: {@link #fromHtml}, | ||||
|      * {@link #fromMarkdown} or {@link #fromPlainText}. | ||||
|      * | ||||
|      * @param textView           the TextView to set the htmlBlock linked | ||||
|      * @param description        the htmlBlock to be linked | ||||
|      * @param htmlCompatFlag     the int flag to be set if {@link HtmlCompat#fromHtml(String, int)} | ||||
|      *                           will be called (not used for formats different than HTML) | ||||
|      * @param relatedInfoService if given, handle hashtags to search for the term in the correct | ||||
|      *                           service | ||||
|      * @param relatedStreamUrl   if given, used alongside {@code relatedInfoService} to handle | ||||
|      *                           timestamps to open the stream in the popup player at the specific | ||||
|      *                           time | ||||
|      * @param disposables        disposables created by the method are added here and their | ||||
|      *                           lifecycle should be handled by the calling class | ||||
|      * @param onCompletion       will be run when setting text to the textView completes; use {@link | ||||
|      *                           #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable | ||||
|      */ | ||||
|     public static void fromDescription(@NonNull final TextView textView, | ||||
|                                        @NonNull final Description description, | ||||
|                                        final int htmlCompatFlag, | ||||
|                                        @Nullable final StreamingService relatedInfoService, | ||||
|                                        @Nullable final String relatedStreamUrl, | ||||
|                                        @NonNull final CompositeDisposable disposables, | ||||
|                                        @Nullable final Consumer<TextView> onCompletion) { | ||||
|         switch (description.getType()) { | ||||
|             case Description.HTML: | ||||
|                 TextLinkifier.fromHtml(textView, description.getContent(), htmlCompatFlag, | ||||
|                         relatedInfoService, relatedStreamUrl, disposables, onCompletion); | ||||
|                 break; | ||||
|             case Description.MARKDOWN: | ||||
|                 TextLinkifier.fromMarkdown(textView, description.getContent(), | ||||
|                         relatedInfoService, relatedStreamUrl, disposables, onCompletion); | ||||
|                 break; | ||||
|             case Description.PLAIN_TEXT: default: | ||||
|                 TextLinkifier.fromPlainText(textView, description.getContent(), | ||||
|                         relatedInfoService, relatedStreamUrl, disposables, onCompletion); | ||||
|                 break; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create links for contents with an HTML description. | ||||
|      * | ||||
|      * <p> | ||||
|      * This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService, | ||||
|      * String, CompositeDisposable, Consumer)} after having linked the URLs with | ||||
|      * {@link HtmlCompat#fromHtml(String, int)}. | ||||
|      * </p> | ||||
|      * | ||||
|      * @param textView           the {@link TextView} to set the the HTML string block linked | ||||
|      * @param htmlBlock          the HTML string block to be linked | ||||
|      * @param htmlCompatFlag     the int flag to be set when {@link HtmlCompat#fromHtml(String, | ||||
|      *                           int)} will be called | ||||
|      * @param relatedInfoService if given, handle hashtags to search for the term in the correct | ||||
|      *                           service | ||||
|      * @param relatedStreamUrl   if given, used alongside {@code relatedInfoService} to handle | ||||
|      *                           timestamps to open the stream in the popup player at the specific | ||||
|      *                           time | ||||
|      * @param disposables        disposables created by the method are added here and their | ||||
|      *                           lifecycle should be handled by the calling class | ||||
|      * @param onCompletion       will be run when setting text to the textView completes; use {@link | ||||
|      *                           #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable | ||||
|      */ | ||||
|     public static void fromHtml(@NonNull final TextView textView, | ||||
|                                 @NonNull final String htmlBlock, | ||||
|                                 final int htmlCompatFlag, | ||||
|                                 @Nullable final StreamingService relatedInfoService, | ||||
|                                 @Nullable final String relatedStreamUrl, | ||||
|                                 @NonNull final CompositeDisposable disposables, | ||||
|                                 @Nullable final Consumer<TextView> onCompletion) { | ||||
|         changeLinkIntents( | ||||
|                 textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfoService, | ||||
|                 relatedStreamUrl, disposables, onCompletion); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create links for contents with a plain text description. | ||||
|      * | ||||
|      * <p> | ||||
|      * This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService, | ||||
|      * String, CompositeDisposable, Consumer)} after having linked the URLs with | ||||
|      * {@link TextView#setAutoLinkMask(int)} and | ||||
|      * {@link TextView#setText(CharSequence, TextView.BufferType)}. | ||||
|      * </p> | ||||
|      * | ||||
|      * @param textView           the {@link TextView} to set the plain text block linked | ||||
|      * @param plainTextBlock     the block of plain text to be linked | ||||
|      * @param relatedInfoService if given, handle hashtags to search for the term in the correct | ||||
|      *                           service | ||||
|      * @param relatedStreamUrl   if given, used alongside {@code relatedInfoService} to handle | ||||
|      *                           timestamps to open the stream in the popup player at the specific | ||||
|      *                           time | ||||
|      * @param disposables        disposables created by the method are added here and their | ||||
|      *                           lifecycle should be handled by the calling class | ||||
|      * @param onCompletion       will be run when setting text to the textView completes; use {@link | ||||
|      *                           #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable | ||||
|      */ | ||||
|     public static void fromPlainText(@NonNull final TextView textView, | ||||
|                                      @NonNull final String plainTextBlock, | ||||
|                                      @Nullable final StreamingService relatedInfoService, | ||||
|                                      @Nullable final String relatedStreamUrl, | ||||
|                                      @NonNull final CompositeDisposable disposables, | ||||
|                                      @Nullable final Consumer<TextView> onCompletion) { | ||||
|         textView.setAutoLinkMask(Linkify.WEB_URLS); | ||||
|         textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE); | ||||
|         changeLinkIntents(textView, textView.getText(), relatedInfoService, | ||||
|                 relatedStreamUrl, disposables, onCompletion); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Create links for contents with a markdown description. | ||||
|      * | ||||
|      * <p> | ||||
|      * This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService, | ||||
|      * String, CompositeDisposable, Consumer)} after creating a {@link Markwon} object and using | ||||
|      * {@link Markwon#setMarkdown(TextView, String)}. | ||||
|      * </p> | ||||
|      * | ||||
|      * @param textView           the {@link TextView} to set the plain text block linked | ||||
|      * @param markdownBlock      the block of markdown text to be linked | ||||
|      * @param relatedInfoService if given, handle hashtags to search for the term in the correct | ||||
|      *                           service | ||||
|      * @param relatedStreamUrl   if given, used alongside {@code relatedInfoService} to handle | ||||
|      *                           timestamps to open the stream in the popup player at the specific | ||||
|      *                           time | ||||
|      * @param disposables        disposables created by the method are added here and their | ||||
|      *                           lifecycle should be handled by the calling class | ||||
|      * @param onCompletion       will be run when setting text to the textView completes; use {@link | ||||
|      *                           #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable | ||||
|      */ | ||||
|     public static void fromMarkdown(@NonNull final TextView textView, | ||||
|                                     @NonNull final String markdownBlock, | ||||
|                                     @Nullable final StreamingService relatedInfoService, | ||||
|                                     @Nullable final String relatedStreamUrl, | ||||
|                                     @NonNull final CompositeDisposable disposables, | ||||
|                                     @Nullable final Consumer<TextView> onCompletion) { | ||||
|         final Markwon markwon = Markwon.builder(textView.getContext()) | ||||
|                 .usePlugin(LinkifyPlugin.create()).build(); | ||||
|         changeLinkIntents(textView, markwon.toMarkdown(markdownBlock), | ||||
|                 relatedInfoService, relatedStreamUrl, disposables, onCompletion); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Change links generated by libraries in the description of a content to a custom link action | ||||
|      * and add click listeners on timestamps in this description. | ||||
|      * | ||||
|      * <p> | ||||
|      * Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of | ||||
|      * a content, this method will parse the {@link CharSequence} and replace all current web links | ||||
|      * with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}. | ||||
|      * </p> | ||||
|      * | ||||
|      * <p> | ||||
|      * This method will also add click listeners on timestamps in this description, which will play | ||||
|      * the content in the popup player at the time indicated in the timestamp, by using | ||||
|      * {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder, | ||||
|      * StreamingService, String, CompositeDisposable)} method and click listeners on hashtags, by | ||||
|      * using {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder, | ||||
|      * StreamingService)}, which will open a search on the current service with the hashtag. | ||||
|      * </p> | ||||
|      * | ||||
|      * <p> | ||||
|      * This method is required in order to intercept links and e.g. show a confirmation dialog | ||||
|      * before opening a web link. | ||||
|      * </p> | ||||
|      * | ||||
|      * @param textView           the {@link TextView} to which the converted {@link CharSequence} | ||||
|      *                           will be applied | ||||
|      * @param chars              the {@link CharSequence} to be parsed | ||||
|      * @param relatedInfoService if given, handle hashtags to search for the term in the correct | ||||
|      *                           service | ||||
|      * @param relatedStreamUrl   if given, used alongside {@code relatedInfoService} to handle | ||||
|      *                           timestamps to open the stream in the popup player at the specific | ||||
|      *                           time | ||||
|      * @param disposables        disposables created by the method are added here and their | ||||
|      *                           lifecycle should be handled by the calling class | ||||
|      * @param onCompletion       will be run when setting text to the textView completes; use {@link | ||||
|      *                           #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable | ||||
|      */ | ||||
|     private static void changeLinkIntents(@NonNull final TextView textView, | ||||
|                                           @NonNull final CharSequence chars, | ||||
|                                           @Nullable final StreamingService relatedInfoService, | ||||
|                                           @Nullable final String relatedStreamUrl, | ||||
|                                           @NonNull final CompositeDisposable disposables, | ||||
|                                           @Nullable final Consumer<TextView> onCompletion) { | ||||
|         disposables.add(Single.fromCallable(() -> { | ||||
|                     final Context context = textView.getContext(); | ||||
|  | ||||
|                     // add custom click actions on web links | ||||
|                     final SpannableStringBuilder textBlockLinked = | ||||
|                             new SpannableStringBuilder(chars); | ||||
|                     final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), | ||||
|                             URLSpan.class); | ||||
|  | ||||
|                     for (final URLSpan span : urls) { | ||||
|                         final String url = span.getURL(); | ||||
|                         final LongPressClickableSpan longPressClickableSpan = | ||||
|                                 new UrlLongPressClickableSpan(context, disposables, url); | ||||
|  | ||||
|                         textBlockLinked.setSpan(longPressClickableSpan, | ||||
|                                 textBlockLinked.getSpanStart(span), | ||||
|                                 textBlockLinked.getSpanEnd(span), | ||||
|                                 textBlockLinked.getSpanFlags(span)); | ||||
|                         textBlockLinked.removeSpan(span); | ||||
|                     } | ||||
|  | ||||
|                     // add click actions on plain text timestamps only for description of contents, | ||||
|                     // unneeded for meta-info or other TextViews | ||||
|                     if (relatedInfoService != null) { | ||||
|                         if (relatedStreamUrl != null) { | ||||
|                             addClickListenersOnTimestamps(context, textBlockLinked, | ||||
|                                     relatedInfoService, relatedStreamUrl, disposables); | ||||
|                         } | ||||
|                         addClickListenersOnHashtags(context, textBlockLinked, relatedInfoService); | ||||
|                     } | ||||
|  | ||||
|                     return textBlockLinked; | ||||
|                 }).subscribeOn(Schedulers.computation()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribe( | ||||
|                         textBlockLinked -> | ||||
|                                 setTextViewCharSequence(textView, textBlockLinked, onCompletion), | ||||
|                         throwable -> { | ||||
|                             Log.e(TAG, "Unable to linkify text", throwable); | ||||
|                             // this should never happen, but if it does, just fallback to it | ||||
|                             setTextViewCharSequence(textView, chars, onCompletion); | ||||
|                         })); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add click listeners which opens a search on hashtags in a plain text. | ||||
|      * | ||||
|      * <p> | ||||
|      * This method finds all timestamps in the {@link SpannableStringBuilder} of the description | ||||
|      * using a regular expression, adds for each a {@link LongPressClickableSpan} which opens | ||||
|      * {@link NavigationHelper#openSearch(Context, int, String)} and makes a search on the hashtag, | ||||
|      * in the service of the content when pressed, and copy the hashtag to clipboard when | ||||
|      * long-pressed, if allowed by the caller method (parameter {@code addLongClickCopyListener}). | ||||
|      * </p> | ||||
|      * | ||||
|      * @param context              the {@link Context} to use | ||||
|      * @param spannableDescription the {@link SpannableStringBuilder} with the text of the | ||||
|      *                             content description | ||||
|      * @param relatedInfoService   used to search for the term in the correct service | ||||
|      */ | ||||
|     private static void addClickListenersOnHashtags( | ||||
|             @NonNull final Context context, | ||||
|             @NonNull final SpannableStringBuilder spannableDescription, | ||||
|             @NonNull final StreamingService relatedInfoService) { | ||||
|         final String descriptionText = spannableDescription.toString(); | ||||
|         final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText); | ||||
|  | ||||
|         while (hashtagsMatches.find()) { | ||||
|             final int hashtagStart = hashtagsMatches.start(1); | ||||
|             final int hashtagEnd = hashtagsMatches.end(1); | ||||
|             final String parsedHashtag = descriptionText.substring(hashtagStart, hashtagEnd); | ||||
|  | ||||
|             // Don't add a LongPressClickableSpan if there is already one, which should be a part | ||||
|             // of an URL, already parsed before | ||||
|             if (spannableDescription.getSpans(hashtagStart, hashtagEnd, | ||||
|                     LongPressClickableSpan.class).length == 0) { | ||||
|                 final int serviceId = relatedInfoService.getServiceId(); | ||||
|                 spannableDescription.setSpan( | ||||
|                         new HashtagLongPressClickableSpan(context, parsedHashtag, serviceId), | ||||
|                         hashtagStart, hashtagEnd, 0); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Add click listeners which opens the popup player on timestamps in a plain text. | ||||
|      * | ||||
|      * <p> | ||||
|      * This method finds all timestamps in the {@link SpannableStringBuilder} of the description | ||||
|      * using a regular expression, adds for each a {@link LongPressClickableSpan} which opens the | ||||
|      * popup player at the time indicated in the timestamps and copy the timestamp in clipboard | ||||
|      * when long-pressed. | ||||
|      * </p> | ||||
|      * | ||||
|      * @param context              the {@link Context} to use | ||||
|      * @param spannableDescription the {@link SpannableStringBuilder} with the text of the | ||||
|      *                             content description | ||||
|      * @param relatedInfoService   the service of the {@code relatedStreamUrl} | ||||
|      * @param relatedStreamUrl     what to open in the popup player when timestamps are clicked | ||||
|      * @param disposables          disposables created by the method are added here and their | ||||
|      *                             lifecycle should be handled by the calling class | ||||
|      */ | ||||
|     private static void addClickListenersOnTimestamps( | ||||
|             @NonNull final Context context, | ||||
|             @NonNull final SpannableStringBuilder spannableDescription, | ||||
|             @NonNull final StreamingService relatedInfoService, | ||||
|             @NonNull final String relatedStreamUrl, | ||||
|             @NonNull final CompositeDisposable disposables) { | ||||
|         final String descriptionText = spannableDescription.toString(); | ||||
|         final Matcher timestampsMatches = TimestampExtractor.TIMESTAMPS_PATTERN.matcher( | ||||
|                 descriptionText); | ||||
|  | ||||
|         while (timestampsMatches.find()) { | ||||
|             final TimestampExtractor.TimestampMatchDTO timestampMatchDTO = | ||||
|                     TimestampExtractor.getTimestampFromMatcher(timestampsMatches, descriptionText); | ||||
|  | ||||
|             if (timestampMatchDTO == null) { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             spannableDescription.setSpan( | ||||
|                     new TimestampLongPressClickableSpan(context, descriptionText, disposables, | ||||
|                             relatedInfoService, relatedStreamUrl, timestampMatchDTO), | ||||
|                     timestampMatchDTO.timestampStart(), | ||||
|                     timestampMatchDTO.timestampEnd(), | ||||
|                     0); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static void setTextViewCharSequence(@NonNull final TextView textView, | ||||
|                                                 @Nullable final CharSequence charSequence, | ||||
|                                                 @Nullable final Consumer<TextView> onCompletion) { | ||||
|         textView.setText(charSequence); | ||||
|         textView.setVisibility(View.VISIBLE); | ||||
|         if (onCompletion != null) { | ||||
|             onCompletion.accept(textView); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,4 +1,7 @@ | ||||
| package org.schabi.newpipe.util.external_communication; | ||||
| package org.schabi.newpipe.util.text; | ||||
| 
 | ||||
| import androidx.annotation.NonNull; | ||||
| import androidx.annotation.Nullable; | ||||
| 
 | ||||
| import java.util.regex.Matcher; | ||||
| import java.util.regex.Pattern; | ||||
| @@ -15,17 +18,18 @@ public final class TimestampExtractor { | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get's a single timestamp from a matcher. | ||||
|      * Gets a single timestamp from a matcher. | ||||
|      * | ||||
|      * @param timestampMatches The matcher which was created using {@link #TIMESTAMPS_PATTERN} | ||||
|      * @param baseText         The text where the pattern was applied to / | ||||
|      *                         where the matcher is based upon | ||||
|      * @return If a match occurred: a {@link TimestampMatchDTO} filled with information.<br/> | ||||
|      * If not <code>null</code>. | ||||
|      * @param timestampMatches the matcher which was created using {@link #TIMESTAMPS_PATTERN} | ||||
|      * @param baseText         the text where the pattern was applied to / where the matcher is | ||||
|      *                         based upon | ||||
|      * @return if a match occurred, a {@link TimestampMatchDTO} filled with information, otherwise | ||||
|      * {@code null}. | ||||
|      */ | ||||
|     @Nullable | ||||
|     public static TimestampMatchDTO getTimestampFromMatcher( | ||||
|             final Matcher timestampMatches, | ||||
|             final String baseText) { | ||||
|             @NonNull final Matcher timestampMatches, | ||||
|             @NonNull final String baseText) { | ||||
|         int timestampStart = timestampMatches.start(1); | ||||
|         if (timestampStart == -1) { | ||||
|             timestampStart = timestampMatches.start(2); | ||||
| @@ -0,0 +1,78 @@ | ||||
| package org.schabi.newpipe.util.text; | ||||
|  | ||||
| import static org.schabi.newpipe.util.text.InternalUrlsHandler.playOnPopup; | ||||
|  | ||||
| import android.content.Context; | ||||
| import android.view.View; | ||||
|  | ||||
| import androidx.annotation.NonNull; | ||||
|  | ||||
| import org.schabi.newpipe.extractor.ServiceList; | ||||
| import org.schabi.newpipe.extractor.StreamingService; | ||||
| import org.schabi.newpipe.util.external_communication.ShareUtils; | ||||
|  | ||||
| import io.reactivex.rxjava3.disposables.CompositeDisposable; | ||||
|  | ||||
| final class TimestampLongPressClickableSpan extends LongPressClickableSpan { | ||||
|  | ||||
|     @NonNull | ||||
|     private final Context context; | ||||
|     @NonNull | ||||
|     private final String descriptionText; | ||||
|     @NonNull | ||||
|     private final CompositeDisposable disposables; | ||||
|     @NonNull | ||||
|     private final StreamingService relatedInfoService; | ||||
|     @NonNull | ||||
|     private final String relatedStreamUrl; | ||||
|     @NonNull | ||||
|     private final TimestampExtractor.TimestampMatchDTO timestampMatchDTO; | ||||
|  | ||||
|     TimestampLongPressClickableSpan( | ||||
|             @NonNull final Context context, | ||||
|             @NonNull final String descriptionText, | ||||
|             @NonNull final CompositeDisposable disposables, | ||||
|             @NonNull final StreamingService relatedInfoService, | ||||
|             @NonNull final String relatedStreamUrl, | ||||
|             @NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) { | ||||
|         this.context = context; | ||||
|         this.descriptionText = descriptionText; | ||||
|         this.disposables = disposables; | ||||
|         this.relatedInfoService = relatedInfoService; | ||||
|         this.relatedStreamUrl = relatedStreamUrl; | ||||
|         this.timestampMatchDTO = timestampMatchDTO; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void onClick(@NonNull final View view) { | ||||
|         playOnPopup(context, relatedStreamUrl, relatedInfoService, | ||||
|                 timestampMatchDTO.seconds(), disposables); | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void onLongClick(@NonNull final View view) { | ||||
|         ShareUtils.copyToClipboard(context, getTimestampTextToCopy( | ||||
|                 relatedInfoService, relatedStreamUrl, descriptionText, timestampMatchDTO)); | ||||
|     } | ||||
|  | ||||
|     @NonNull | ||||
|     private static String getTimestampTextToCopy( | ||||
|             @NonNull final StreamingService relatedInfoService, | ||||
|             @NonNull final String relatedStreamUrl, | ||||
|             @NonNull final String descriptionText, | ||||
|             @NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) { | ||||
|         // TODO: use extractor methods to get timestamps when this feature will be implemented in it | ||||
|         if (relatedInfoService == ServiceList.YouTube) { | ||||
|             return relatedStreamUrl + "&t=" + timestampMatchDTO.seconds(); | ||||
|         } else if (relatedInfoService == ServiceList.SoundCloud | ||||
|                 || relatedInfoService == ServiceList.MediaCCC) { | ||||
|             return relatedStreamUrl + "#t=" + timestampMatchDTO.seconds(); | ||||
|         } else if (relatedInfoService == ServiceList.PeerTube) { | ||||
|             return relatedStreamUrl + "?start=" + timestampMatchDTO.seconds(); | ||||
|         } | ||||
|  | ||||
|         // Return timestamp text for other services | ||||
|         return descriptionText.subSequence(timestampMatchDTO.timestampStart(), | ||||
|                 timestampMatchDTO.timestampEnd()).toString(); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,38 @@ | ||||
| package org.schabi.newpipe.util.text; | ||||
|  | ||||
| import android.text.Layout; | ||||
| import android.view.MotionEvent; | ||||
| import android.widget.TextView; | ||||
|  | ||||
| import androidx.annotation.NonNull; | ||||
|  | ||||
| public final class TouchUtils { | ||||
|  | ||||
|     private TouchUtils() { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get the character offset on the closest line to the position pressed by the user of a | ||||
|      * {@link TextView} from a {@link MotionEvent} which was fired on this {@link TextView}. | ||||
|      * | ||||
|      * @param textView the {@link TextView} on which the {@link MotionEvent} was fired | ||||
|      * @param event    the {@link MotionEvent} which was fired | ||||
|      * @return the character offset on the closest line to the position pressed by the user | ||||
|      */ | ||||
|     public static int getOffsetForHorizontalLine(@NonNull final TextView textView, | ||||
|                                                  @NonNull final MotionEvent event) { | ||||
|  | ||||
|         int x = (int) event.getX(); | ||||
|         int y = (int) event.getY(); | ||||
|  | ||||
|         x -= textView.getTotalPaddingLeft(); | ||||
|         y -= textView.getTotalPaddingTop(); | ||||
|  | ||||
|         x += textView.getScrollX(); | ||||
|         y += textView.getScrollY(); | ||||
|  | ||||
|         final Layout layout = textView.getLayout(); | ||||
|         final int line = layout.getLineForVertical(y); | ||||
|         return layout.getOffsetForHorizontal(line, x); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,41 @@ | ||||
| package org.schabi.newpipe.util.text; | ||||
|  | ||||
| import android.content.Context; | ||||
| import android.view.View; | ||||
|  | ||||
| import androidx.annotation.NonNull; | ||||
|  | ||||
| import org.schabi.newpipe.util.external_communication.ShareUtils; | ||||
|  | ||||
| import io.reactivex.rxjava3.disposables.CompositeDisposable; | ||||
|  | ||||
| final class UrlLongPressClickableSpan extends LongPressClickableSpan { | ||||
|  | ||||
|     @NonNull | ||||
|     private final Context context; | ||||
|     @NonNull | ||||
|     private final CompositeDisposable disposables; | ||||
|     @NonNull | ||||
|     private final String url; | ||||
|  | ||||
|     UrlLongPressClickableSpan(@NonNull final Context context, | ||||
|                               @NonNull final CompositeDisposable disposables, | ||||
|                               @NonNull final String url) { | ||||
|         this.context = context; | ||||
|         this.disposables = disposables; | ||||
|         this.url = url; | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void onClick(@NonNull final View view) { | ||||
|         if (!InternalUrlsHandler.handleUrlDescriptionTimestamp( | ||||
|                 disposables, context, url)) { | ||||
|             ShareUtils.openUrlInBrowser(context, url, false); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     @Override | ||||
|     public void onLongClick(@NonNull final View view) { | ||||
|         ShareUtils.copyToClipboard(context, url); | ||||
|     } | ||||
| } | ||||
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