diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 311e5248c..d08ae9051 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -68,7 +68,7 @@ The [checkStyle](https://github.com/checkstyle/checkstyle) plugin verifies that
- Go to `File -> Settings -> Plugins`, search for `checkstyle` and install `CheckStyle-IDEA`.
- Go to `File -> Settings -> Tools -> Checkstyle`.
- Add NewPipe's configuration file by clicking the `+` in the right toolbar of the "Configuration File" list.
-- Under the "Use a local Checkstyle file" bullet, click on `Browse` and pick the file named `checkstyle.xml` in the project's root folder.
+- Under the "Use a local Checkstyle file" bullet, click on `Browse` and, enter `checkstyle` folder under the project's root path and pick the file named `checkstyle.xml`.
- Enable "Store relative to project location" so that moving the directory around does not create issues.
- Insert a description in the top bar, then click `Next` and then `Finish`.
- Activate the configuration file you just added by enabling the checkbox on the left.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7dbfadc0b..306b8c2c8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,6 +6,7 @@ on:
branches:
- dev
- master
+ - release/**
paths-ignore:
- 'README.md'
- 'doc/**'
@@ -31,7 +32,7 @@ jobs:
build-and-test-jvm:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- uses: gradle/wrapper-validation-action@v1
- name: create and checkout branch
@@ -40,7 +41,7 @@ jobs:
run: git checkout -B ${{ github.head_ref }}
- name: set up JDK 11
- uses: actions/setup-java@v2
+ uses: actions/setup-java@v3
with:
java-version: 11
distribution: "temurin"
@@ -50,7 +51,7 @@ jobs:
run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint
- name: Upload APK
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v3
with:
name: app
path: app/build/outputs/apk/debug/*.apk
@@ -64,10 +65,10 @@ jobs:
# api-level 19 is min sdk, but throws errors related to desugaring
api-level: [ 21, 29 ]
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: set up JDK 11
- uses: actions/setup-java@v2
+ uses: actions/setup-java@v3
with:
java-version: 11
distribution: "temurin"
@@ -82,7 +83,7 @@ jobs:
script: ./gradlew connectedCheck --stacktrace
- name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553
- uses: actions/upload-artifact@v2
+ uses: actions/upload-artifact@v3
if: failure()
with:
name: android-test-report-api${{ matrix.api-level }}
@@ -91,19 +92,19 @@ jobs:
sonar:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Set up JDK 11
- uses: actions/setup-java@v2
+ uses: actions/setup-java@v3
with:
java-version: 11 # Sonar requires JDK 11
distribution: "temurin"
cache: 'gradle'
- name: Cache SonarCloud packages
- uses: actions/cache@v2
+ uses: actions/cache@v3
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
diff --git a/.github/workflows/image-minimizer.yml b/.github/workflows/image-minimizer.yml
index 77b1faecf..c6ab6d5b3 100644
--- a/.github/workflows/image-minimizer.yml
+++ b/.github/workflows/image-minimizer.yml
@@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- - uses: actions/setup-node@v2
+ - uses: actions/setup-node@v3
with:
node-version: 16
@@ -21,7 +21,7 @@ jobs:
run: npm i probe-image-size@7.2.3 --ignore-scripts
- name: Minimize simple images
- uses: actions/github-script@v5
+ uses: actions/github-script@v6
timeout-minutes: 3
with:
script: |
diff --git a/LICENSE b/LICENSE
index 94a9ed024..f288702d2 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,7 +1,7 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
- Copyright (C) 2007 Free Software Foundation, Inc.
+ Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
@@ -645,7 +645,7 @@ the "copyright" line and a pointer to where the full notice is found.
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
- along with this program. If not, see .
+ along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
@@ -664,11 +664,11 @@ might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
-.
+.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
-.
+.
diff --git a/app/build.gradle b/app/build.gradle
index f954d88e5..19c491a4d 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -9,15 +9,15 @@ plugins {
android {
compileSdk 31
- buildToolsVersion '30.0.3'
+ buildToolsVersion '31.0.0'
defaultConfig {
applicationId "org.schabi.newpipe"
resValue "string", "app_name", "NewPipe"
minSdk 19
targetSdk 29
- versionCode 985
- versionName "0.22.2"
+ versionCode 986
+ versionName "0.23.0"
multiDexEnabled true
@@ -98,13 +98,14 @@ android {
}
ext {
- checkstyleVersion = '9.2.1'
+ checkstyleVersion = '10.0'
androidxLifecycleVersion = '2.3.1'
- androidxRoomVersion = '2.3.0'
+ androidxRoomVersion = '2.4.2'
+ androidxWorkVersion = '2.7.1'
icepickVersion = '3.2.0'
- exoPlayerVersion = '2.14.2'
+ exoPlayerVersion = '2.17.1'
googleAutoServiceVersion = '1.0.1'
groupieVersion = '2.10.0'
markwonVersion = '4.6.2'
@@ -121,7 +122,7 @@ configurations {
}
checkstyle {
- getConfigDirectory().set(rootProject.file("."))
+ getConfigDirectory().set(rootProject.file("checkstyle"))
ignoreFailures false
showViolations true
toolVersion = checkstyleVersion
@@ -189,11 +190,11 @@ dependencies {
// name and the commit hash with the commit hash of the (pushed) commit you want to test
// This works thanks to JitPack: https://jitpack.io/
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
- implementation 'com.github.TeamNewPipe:NewPipeExtractor:b77c72fb8826c3ffca0be5f96b066cca0a07b1c9'
+ implementation 'com.github.TeamNewPipe:NewPipeExtractor:ac1c22d81c65b7b0c5427f4e1989f5256d617f32'
/** Checkstyle **/
checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}"
- ktlint 'com.pinterest:ktlint:0.43.2'
+ ktlint 'com.pinterest:ktlint:0.44.0'
/** Kotlin **/
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}"
@@ -201,16 +202,16 @@ dependencies {
/** AndroidX **/
implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'androidx.cardview:cardview:1.0.0'
- implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'androidx.fragment:fragment-ktx:1.3.6'
implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}"
implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}"
- implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0'
- implementation 'androidx.media:media:1.4.3'
+ implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
+ implementation 'androidx.media:media:1.5.0'
implementation 'androidx.multidex:multidex:2.0.1'
- implementation 'androidx.preference:preference:1.1.1'
+ implementation 'androidx.preference:preference:1.2.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation "androidx.room:room-runtime:${androidxRoomVersion}"
implementation "androidx.room:room-rxjava3:${androidxRoomVersion}"
@@ -220,7 +221,10 @@ dependencies {
// https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.webkit:webkit:1.4.0'
- implementation 'com.google.android.material:material:1.4.0'
+ implementation 'com.google.android.material:material:1.5.0'
+ implementation "androidx.work:work-runtime:${androidxWorkVersion}"
+ implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}"
+ implementation "androidx.work:work-rxjava3:${androidxWorkVersion}"
/** Third-party libraries **/
// Instance state boilerplate elimination
@@ -246,8 +250,6 @@ dependencies {
implementation "com.github.lisawray.groupie:groupie:${groupieVersion}"
implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}"
- // Circular ImageView
- implementation "de.hdodenhof:circleimageview:3.1.0"
// Image loading
//noinspection GradleDependency --> 2.8 is the last version, not 2.71828!
implementation "com.squareup.picasso:picasso:2.8"
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 53a9ecd5a..4a54d8992 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -51,3 +51,6 @@
private void writeObject(java.io.ObjectOutputStream);
private void readObject(java.io.ObjectInputStream);
}
+
+# for some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml)
+-keep class org.schabi.newpipe.settings.notifications.** { *; }
diff --git a/app/schemas/org.schabi.newpipe.database.AppDatabase/5.json b/app/schemas/org.schabi.newpipe.database.AppDatabase/5.json
new file mode 100644
index 000000000..9a1c62995
--- /dev/null
+++ b/app/schemas/org.schabi.newpipe.database.AppDatabase/5.json
@@ -0,0 +1,719 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 5,
+ "identityHash": "096731b513bb71dd44517639f4a2c1e3",
+ "entities": [
+ {
+ "tableName": "subscriptions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "uid",
+ "columnName": "uid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serviceId",
+ "columnName": "service_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "avatarUrl",
+ "columnName": "avatar_url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "subscriberCount",
+ "columnName": "subscriber_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "notificationMode",
+ "columnName": "notification_mode",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "uid"
+ ],
+ "autoGenerate": true
+ },
+ "indices": [
+ {
+ "name": "index_subscriptions_service_id_url",
+ "unique": true,
+ "columnNames": [
+ "service_id",
+ "url"
+ ],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "search_history",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "creationDate",
+ "columnName": "creation_date",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "serviceId",
+ "columnName": "service_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "search",
+ "columnName": "search",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "id"
+ ],
+ "autoGenerate": true
+ },
+ "indices": [
+ {
+ "name": "index_search_history_search",
+ "unique": false,
+ "columnNames": [
+ "search"
+ ],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "streams",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)",
+ "fields": [
+ {
+ "fieldPath": "uid",
+ "columnName": "uid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serviceId",
+ "columnName": "service_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "streamType",
+ "columnName": "stream_type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uploader",
+ "columnName": "uploader",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "uploaderUrl",
+ "columnName": "uploader_url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnail_url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "viewCount",
+ "columnName": "view_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "textualUploadDate",
+ "columnName": "textual_upload_date",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "uploadDate",
+ "columnName": "upload_date",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isUploadDateApproximation",
+ "columnName": "is_upload_date_approximation",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "uid"
+ ],
+ "autoGenerate": true
+ },
+ "indices": [
+ {
+ "name": "index_streams_service_id_url",
+ "unique": true,
+ "columnNames": [
+ "service_id",
+ "url"
+ ],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "stream_history",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "streamUid",
+ "columnName": "stream_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "accessDate",
+ "columnName": "access_date",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "repeatCount",
+ "columnName": "repeat_count",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "stream_id",
+ "access_date"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [
+ {
+ "name": "index_stream_history_stream_id",
+ "unique": false,
+ "columnNames": [
+ "stream_id"
+ ],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "streams",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "stream_id"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "stream_state",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "streamUid",
+ "columnName": "stream_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "progressMillis",
+ "columnName": "progress_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "stream_id"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "streams",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "stream_id"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT)",
+ "fields": [
+ {
+ "fieldPath": "uid",
+ "columnName": "uid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnail_url",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "uid"
+ ],
+ "autoGenerate": true
+ },
+ "indices": [
+ {
+ "name": "index_playlists_name",
+ "unique": false,
+ "columnNames": [
+ "name"
+ ],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`name`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "playlist_stream_join",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
+ "fields": [
+ {
+ "fieldPath": "playlistUid",
+ "columnName": "playlist_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "streamUid",
+ "columnName": "stream_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "index",
+ "columnName": "join_index",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "playlist_id",
+ "join_index"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [
+ {
+ "name": "index_playlist_stream_join_playlist_id_join_index",
+ "unique": true,
+ "columnNames": [
+ "playlist_id",
+ "join_index"
+ ],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)"
+ },
+ {
+ "name": "index_playlist_stream_join_stream_id",
+ "unique": false,
+ "columnNames": [
+ "stream_id"
+ ],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "playlists",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "playlist_id"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ },
+ {
+ "table": "streams",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "stream_id"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "remote_playlists",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)",
+ "fields": [
+ {
+ "fieldPath": "uid",
+ "columnName": "uid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serviceId",
+ "columnName": "service_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "thumbnailUrl",
+ "columnName": "thumbnail_url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "uploader",
+ "columnName": "uploader",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "streamCount",
+ "columnName": "stream_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "uid"
+ ],
+ "autoGenerate": true
+ },
+ "indices": [
+ {
+ "name": "index_remote_playlists_name",
+ "unique": false,
+ "columnNames": [
+ "name"
+ ],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)"
+ },
+ {
+ "name": "index_remote_playlists_service_id_url",
+ "unique": true,
+ "columnNames": [
+ "service_id",
+ "url"
+ ],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "feed",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
+ "fields": [
+ {
+ "fieldPath": "streamId",
+ "columnName": "stream_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subscriptionId",
+ "columnName": "subscription_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "stream_id",
+ "subscription_id"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [
+ {
+ "name": "index_feed_subscription_id",
+ "unique": false,
+ "columnNames": [
+ "subscription_id"
+ ],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "streams",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "stream_id"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ },
+ {
+ "table": "subscriptions",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "subscription_id"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "feed_group",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "uid",
+ "columnName": "uid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "icon",
+ "columnName": "icon_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sortOrder",
+ "columnName": "sort_order",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "uid"
+ ],
+ "autoGenerate": true
+ },
+ "indices": [
+ {
+ "name": "index_feed_group_sort_order",
+ "unique": false,
+ "columnNames": [
+ "sort_order"
+ ],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "feed_group_subscription_join",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
+ "fields": [
+ {
+ "fieldPath": "feedGroupId",
+ "columnName": "group_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subscriptionId",
+ "columnName": "subscription_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "group_id",
+ "subscription_id"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [
+ {
+ "name": "index_feed_group_subscription_join_subscription_id",
+ "unique": false,
+ "columnNames": [
+ "subscription_id"
+ ],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "feed_group",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "group_id"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ },
+ {
+ "table": "subscriptions",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "subscription_id"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "feed_last_updated",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
+ "fields": [
+ {
+ "fieldPath": "subscriptionId",
+ "columnName": "subscription_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "subscription_id"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "subscriptions",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "subscription_id"
+ ],
+ "referencedColumns": [
+ "uid"
+ ]
+ }
+ ]
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '096731b513bb71dd44517639f4a2c1e3')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt b/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt
new file mode 100644
index 000000000..28dea13e9
--- /dev/null
+++ b/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt
@@ -0,0 +1,130 @@
+package org.schabi.newpipe.database
+
+import android.content.ContentValues
+import android.database.sqlite.SQLiteDatabase
+import androidx.room.Room
+import androidx.room.testing.MigrationTestHelper
+import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.schabi.newpipe.extractor.stream.StreamType
+
+@RunWith(AndroidJUnit4::class)
+class DatabaseMigrationTest {
+ companion object {
+ private const val DEFAULT_SERVICE_ID = 0
+ private const val DEFAULT_URL = "https://www.youtube.com/watch?v=cDphUib5iG4"
+ private const val DEFAULT_TITLE = "Test Title"
+ private val DEFAULT_TYPE = StreamType.VIDEO_STREAM
+ private const val DEFAULT_DURATION = 480L
+ private const val DEFAULT_UPLOADER_NAME = "Uploader Test"
+ private const val DEFAULT_THUMBNAIL = "https://example.com/example.jpg"
+
+ private const val DEFAULT_SECOND_SERVICE_ID = 0
+ private const val DEFAULT_SECOND_URL = "https://www.youtube.com/watch?v=ncQU6iBn5Fc"
+ }
+
+ @get:Rule
+ val testHelper = MigrationTestHelper(
+ InstrumentationRegistry.getInstrumentation(),
+ AppDatabase::class.java.canonicalName, FrameworkSQLiteOpenHelperFactory()
+ )
+
+ @Test
+ fun migrateDatabaseFrom2to3() {
+ val databaseInV2 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_2)
+
+ databaseInV2.run {
+ insert(
+ "streams", SQLiteDatabase.CONFLICT_FAIL,
+ ContentValues().apply {
+ put("service_id", DEFAULT_SERVICE_ID)
+ put("url", DEFAULT_URL)
+ put("title", DEFAULT_TITLE)
+ put("stream_type", DEFAULT_TYPE.name)
+ put("duration", DEFAULT_DURATION)
+ put("uploader", DEFAULT_UPLOADER_NAME)
+ put("thumbnail_url", DEFAULT_THUMBNAIL)
+ }
+ )
+ insert(
+ "streams", SQLiteDatabase.CONFLICT_FAIL,
+ ContentValues().apply {
+ put("service_id", DEFAULT_SECOND_SERVICE_ID)
+ put("url", DEFAULT_SECOND_URL)
+ }
+ )
+ insert(
+ "streams", SQLiteDatabase.CONFLICT_FAIL,
+ ContentValues().apply {
+ put("service_id", DEFAULT_SERVICE_ID)
+ }
+ )
+ close()
+ }
+
+ testHelper.runMigrationsAndValidate(
+ AppDatabase.DATABASE_NAME, Migrations.DB_VER_3,
+ true, Migrations.MIGRATION_2_3
+ )
+
+ testHelper.runMigrationsAndValidate(
+ AppDatabase.DATABASE_NAME, Migrations.DB_VER_4,
+ true, Migrations.MIGRATION_3_4
+ )
+
+ testHelper.runMigrationsAndValidate(
+ AppDatabase.DATABASE_NAME, Migrations.DB_VER_5,
+ true, Migrations.MIGRATION_4_5
+ )
+
+ val migratedDatabaseV3 = getMigratedDatabase()
+ val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst()
+
+ // Only expect 2, the one with the null url will be ignored
+ assertEquals(2, listFromDB.size)
+
+ val streamFromMigratedDatabase = listFromDB[0]
+ assertEquals(DEFAULT_SERVICE_ID, streamFromMigratedDatabase.serviceId)
+ assertEquals(DEFAULT_URL, streamFromMigratedDatabase.url)
+ assertEquals(DEFAULT_TITLE, streamFromMigratedDatabase.title)
+ assertEquals(DEFAULT_TYPE, streamFromMigratedDatabase.streamType)
+ assertEquals(DEFAULT_DURATION, streamFromMigratedDatabase.duration)
+ assertEquals(DEFAULT_UPLOADER_NAME, streamFromMigratedDatabase.uploader)
+ assertEquals(DEFAULT_THUMBNAIL, streamFromMigratedDatabase.thumbnailUrl)
+ assertNull(streamFromMigratedDatabase.viewCount)
+ assertNull(streamFromMigratedDatabase.textualUploadDate)
+ assertNull(streamFromMigratedDatabase.uploadDate)
+ assertNull(streamFromMigratedDatabase.isUploadDateApproximation)
+
+ val secondStreamFromMigratedDatabase = listFromDB[1]
+ assertEquals(DEFAULT_SECOND_SERVICE_ID, secondStreamFromMigratedDatabase.serviceId)
+ assertEquals(DEFAULT_SECOND_URL, secondStreamFromMigratedDatabase.url)
+ assertEquals("", secondStreamFromMigratedDatabase.title)
+ // Should fallback to VIDEO_STREAM
+ assertEquals(StreamType.VIDEO_STREAM, secondStreamFromMigratedDatabase.streamType)
+ assertEquals(0, secondStreamFromMigratedDatabase.duration)
+ assertEquals("", secondStreamFromMigratedDatabase.uploader)
+ assertEquals("", secondStreamFromMigratedDatabase.thumbnailUrl)
+ assertNull(secondStreamFromMigratedDatabase.viewCount)
+ assertNull(secondStreamFromMigratedDatabase.textualUploadDate)
+ assertNull(secondStreamFromMigratedDatabase.uploadDate)
+ assertNull(secondStreamFromMigratedDatabase.isUploadDateApproximation)
+ }
+
+ private fun getMigratedDatabase(): AppDatabase {
+ val database: AppDatabase = Room.databaseBuilder(
+ ApplicationProvider.getApplicationContext(),
+ AppDatabase::class.java, AppDatabase.DATABASE_NAME
+ )
+ .build()
+ testHelper.closeWhenFinished(database)
+ return database
+ }
+}
diff --git a/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt b/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt
new file mode 100644
index 000000000..a9aa40d82
--- /dev/null
+++ b/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt
@@ -0,0 +1,188 @@
+package org.schabi.newpipe.util
+
+import android.content.Context
+import android.util.SparseArray
+import android.view.View
+import android.view.View.GONE
+import android.view.View.INVISIBLE
+import android.view.View.VISIBLE
+import android.widget.Spinner
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.internal.runner.junit4.statement.UiThreadStatement
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.schabi.newpipe.R
+import org.schabi.newpipe.extractor.MediaFormat
+import org.schabi.newpipe.extractor.stream.AudioStream
+import org.schabi.newpipe.extractor.stream.Stream
+import org.schabi.newpipe.extractor.stream.SubtitlesStream
+import org.schabi.newpipe.extractor.stream.VideoStream
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class StreamItemAdapterTest {
+ private lateinit var context: Context
+ private lateinit var spinner: Spinner
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ UiThreadStatement.runOnUiThread {
+ spinner = Spinner(context)
+ }
+ }
+
+ @Test
+ fun videoStreams_noSecondaryStream() {
+ val adapter = StreamItemAdapter(
+ context,
+ getVideoStreams(true, true, true, true),
+ null
+ )
+
+ spinner.adapter = adapter
+ assertIconVisibility(spinner, 0, VISIBLE, VISIBLE)
+ assertIconVisibility(spinner, 1, VISIBLE, VISIBLE)
+ assertIconVisibility(spinner, 2, VISIBLE, VISIBLE)
+ assertIconVisibility(spinner, 3, VISIBLE, VISIBLE)
+ }
+
+ @Test
+ fun videoStreams_hasSecondaryStream() {
+ val adapter = StreamItemAdapter(
+ context,
+ getVideoStreams(false, true, false, true),
+ getAudioStreams(false, true, false, true)
+ )
+
+ spinner.adapter = adapter
+ assertIconVisibility(spinner, 0, GONE, GONE)
+ assertIconVisibility(spinner, 1, GONE, GONE)
+ assertIconVisibility(spinner, 2, GONE, GONE)
+ assertIconVisibility(spinner, 3, GONE, GONE)
+ }
+
+ @Test
+ fun videoStreams_Mixed() {
+ val adapter = StreamItemAdapter(
+ context,
+ getVideoStreams(true, true, true, true, true, false, true, true),
+ getAudioStreams(false, true, false, false, false, true, true, true)
+ )
+
+ spinner.adapter = adapter
+ assertIconVisibility(spinner, 0, VISIBLE, VISIBLE)
+ assertIconVisibility(spinner, 1, GONE, INVISIBLE)
+ assertIconVisibility(spinner, 2, VISIBLE, VISIBLE)
+ assertIconVisibility(spinner, 3, VISIBLE, VISIBLE)
+ assertIconVisibility(spinner, 4, VISIBLE, VISIBLE)
+ assertIconVisibility(spinner, 5, GONE, INVISIBLE)
+ assertIconVisibility(spinner, 6, GONE, INVISIBLE)
+ assertIconVisibility(spinner, 7, GONE, INVISIBLE)
+ }
+
+ @Test
+ fun subtitleStreams_noIcon() {
+ val adapter = StreamItemAdapter(
+ context,
+ StreamItemAdapter.StreamSizeWrapper(
+ (0 until 5).map {
+ SubtitlesStream(MediaFormat.SRT, "pt-BR", "https://example.com", false)
+ },
+ context
+ ),
+ null
+ )
+ spinner.adapter = adapter
+ for (i in 0 until spinner.count) {
+ assertIconVisibility(spinner, i, GONE, GONE)
+ }
+ }
+
+ @Test
+ fun audioStreams_noIcon() {
+ val adapter = StreamItemAdapter(
+ context,
+ StreamItemAdapter.StreamSizeWrapper(
+ (0 until 5).map { AudioStream("https://example.com/$it", MediaFormat.OPUS, 192) },
+ context
+ ),
+ null
+ )
+ spinner.adapter = adapter
+ for (i in 0 until spinner.count) {
+ assertIconVisibility(spinner, i, GONE, GONE)
+ }
+ }
+
+ /**
+ * @return a list of video streams, in which their video only property mirrors the provided
+ * [videoOnly] vararg.
+ */
+ private fun getVideoStreams(vararg videoOnly: Boolean) =
+ StreamItemAdapter.StreamSizeWrapper(
+ videoOnly.map {
+ VideoStream("https://example.com", MediaFormat.MPEG_4, "720p", it)
+ },
+ context
+ )
+
+ /**
+ * @return a list of audio streams, containing valid and null elements mirroring the provided
+ * [shouldBeValid] vararg.
+ */
+ private fun getAudioStreams(vararg shouldBeValid: Boolean) =
+ getSecondaryStreamsFromList(
+ shouldBeValid.map {
+ if (it) AudioStream("https://example.com", MediaFormat.OPUS, 192)
+ else null
+ }
+ )
+
+ /**
+ * Checks whether the item at [position] in the [spinner] has the correct icon visibility when
+ * it is shown in normal mode (selected) and in dropdown mode (user is choosing one of a list).
+ */
+ private fun assertIconVisibility(
+ spinner: Spinner,
+ position: Int,
+ normalVisibility: Int,
+ dropDownVisibility: Int
+ ) {
+ spinner.setSelection(position)
+ spinner.adapter.getView(position, null, spinner).run {
+ Assert.assertEquals(
+ "normal visibility (pos=[$position]) is not correct",
+ findViewById(R.id.wo_sound_icon).visibility,
+ normalVisibility,
+ )
+ }
+ spinner.adapter.getDropDownView(position, null, spinner).run {
+ Assert.assertEquals(
+ "drop down visibility (pos=[$position]) is not correct",
+ findViewById(R.id.wo_sound_icon).visibility,
+ dropDownVisibility
+ )
+ }
+ }
+
+ /**
+ * Helper function that builds a secondary stream list.
+ */
+ private fun getSecondaryStreamsFromList(streams: List) =
+ SparseArray?>(streams.size).apply {
+ streams.forEachIndexed { index, stream ->
+ val secondaryStreamHelper: SecondaryStreamHelper? = stream?.let {
+ SecondaryStreamHelper(
+ StreamItemAdapter.StreamSizeWrapper(streams, context),
+ it
+ )
+ }
+ put(index, secondaryStreamHelper)
+ }
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 28cdbf020..f9c99819c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -381,9 +381,6 @@
-
diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java
index 54e0af8c6..6b02e21ca 100644
--- a/app/src/main/java/org/schabi/newpipe/App.java
+++ b/app/src/main/java/org/schabi/newpipe/App.java
@@ -27,7 +27,7 @@ import org.schabi.newpipe.util.StateSaver;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.net.SocketException;
-import java.util.Arrays;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -213,37 +213,44 @@ public class App extends MultiDexApplication {
private void initNotificationChannels() {
// Keep the importance below DEFAULT to avoid making noise on every notification update for
// the main and update channels
- final NotificationChannelCompat mainChannel = new NotificationChannelCompat
+ final List notificationChannelCompats = new ArrayList<>();
+ notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.notification_channel_name))
.setDescription(getString(R.string.notification_channel_description))
- .build();
+ .build());
- final NotificationChannelCompat appUpdateChannel = new NotificationChannelCompat
+ notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.app_update_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.app_update_notification_channel_name))
.setDescription(getString(R.string.app_update_notification_channel_description))
- .build();
+ .build());
- final NotificationChannelCompat hashChannel = new NotificationChannelCompat
+ notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.hash_channel_id),
NotificationManagerCompat.IMPORTANCE_HIGH)
.setName(getString(R.string.hash_channel_name))
.setDescription(getString(R.string.hash_channel_description))
- .build();
+ .build());
- final NotificationChannelCompat errorReportChannel = new NotificationChannelCompat
+ notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.error_report_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.error_report_channel_name))
.setDescription(getString(R.string.error_report_channel_description))
- .build();
+ .build());
+
+ notificationChannelCompats.add(new NotificationChannelCompat
+ .Builder(getString(R.string.streams_notification_channel_id),
+ NotificationManagerCompat.IMPORTANCE_DEFAULT)
+ .setName(getString(R.string.streams_notification_channel_name))
+ .setDescription(getString(R.string.streams_notification_channel_description))
+ .build());
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
- notificationManager.createNotificationChannelsCompat(Arrays.asList(mainChannel,
- appUpdateChannel, hashChannel, errorReportChannel));
+ notificationManager.createNotificationChannelsCompat(notificationChannelCompats);
}
protected boolean isDisposedRxExceptionsReported() {
diff --git a/app/src/main/java/org/schabi/newpipe/CheckForNewAppVersion.java b/app/src/main/java/org/schabi/newpipe/CheckForNewAppVersion.java
deleted file mode 100644
index 122660d64..000000000
--- a/app/src/main/java/org/schabi/newpipe/CheckForNewAppVersion.java
+++ /dev/null
@@ -1,264 +0,0 @@
-package org.schabi.newpipe;
-
-import android.app.Application;
-import android.app.IntentService;
-import android.app.PendingIntent;
-import android.content.Intent;
-import android.content.SharedPreferences;
-import android.content.pm.PackageManager;
-import android.content.pm.Signature;
-import android.net.Uri;
-import android.util.Log;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.core.app.NotificationCompat;
-import androidx.core.app.NotificationManagerCompat;
-import androidx.core.content.pm.PackageInfoCompat;
-import androidx.preference.PreferenceManager;
-
-import com.grack.nanojson.JsonObject;
-import com.grack.nanojson.JsonParser;
-import com.grack.nanojson.JsonParserException;
-
-import org.schabi.newpipe.error.ErrorInfo;
-import org.schabi.newpipe.error.ErrorUtil;
-import org.schabi.newpipe.error.UserAction;
-import org.schabi.newpipe.extractor.downloader.Response;
-import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
-
-import java.io.ByteArrayInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.security.cert.CertificateEncodingException;
-import java.security.cert.CertificateException;
-import java.security.cert.CertificateFactory;
-import java.security.cert.X509Certificate;
-import java.util.List;
-
-public final class CheckForNewAppVersion extends IntentService {
- public CheckForNewAppVersion() {
- super("CheckForNewAppVersion");
- }
-
- private static final boolean DEBUG = MainActivity.DEBUG;
- private static final String TAG = CheckForNewAppVersion.class.getSimpleName();
-
- // Public key of the certificate that is used in NewPipe release versions
- private static final String RELEASE_CERT_PUBLIC_KEY_SHA1
- = "B0:2E:90:7C:1C:D6:FC:57:C3:35:F0:88:D0:8F:50:5F:94:E4:D2:15";
- private static final String NEWPIPE_API_URL = "https://newpipe.net/api/data.json";
-
- /**
- * Method to get the APK's SHA1 key. See https://stackoverflow.com/questions/9293019/#22506133.
- *
- * @param application The application
- * @return String with the APK's SHA1 fingerprint in hexadecimal
- */
- @NonNull
- private static String getCertificateSHA1Fingerprint(@NonNull final Application application) {
- final List signatures;
- try {
- signatures = PackageInfoCompat.getSignatures(application.getPackageManager(),
- application.getPackageName());
- } catch (final PackageManager.NameNotFoundException e) {
- ErrorUtil.createNotification(application, new ErrorInfo(e,
- UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not find package info"));
- return "";
- }
- if (signatures.isEmpty()) {
- return "";
- }
-
- final X509Certificate c;
- try {
- final byte[] cert = signatures.get(0).toByteArray();
- final InputStream input = new ByteArrayInputStream(cert);
- final CertificateFactory cf = CertificateFactory.getInstance("X509");
- c = (X509Certificate) cf.generateCertificate(input);
- } catch (final CertificateException e) {
- ErrorUtil.createNotification(application, new ErrorInfo(e,
- UserAction.CHECK_FOR_NEW_APP_VERSION, "Certificate error"));
- return "";
- }
-
- try {
- final MessageDigest md = MessageDigest.getInstance("SHA1");
- final byte[] publicKey = md.digest(c.getEncoded());
- return byte2HexFormatted(publicKey);
- } catch (NoSuchAlgorithmException | CertificateEncodingException e) {
- ErrorUtil.createNotification(application, new ErrorInfo(e,
- UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not retrieve SHA1 key"));
- return "";
- }
- }
-
- private static String byte2HexFormatted(final byte[] arr) {
- final StringBuilder str = new StringBuilder(arr.length * 2);
-
- for (int i = 0; i < arr.length; i++) {
- String h = Integer.toHexString(arr[i]);
- final int l = h.length();
- if (l == 1) {
- h = "0" + h;
- }
- if (l > 2) {
- h = h.substring(l - 2, l);
- }
- str.append(h.toUpperCase());
- if (i < (arr.length - 1)) {
- str.append(':');
- }
- }
- return str.toString();
- }
-
- /**
- * Method to compare the current and latest available app version.
- * If a newer version is available, we show the update notification.
- *
- * @param application The application
- * @param versionName Name of new version
- * @param apkLocationUrl Url with the new apk
- * @param versionCode Code of new version
- */
- private static void compareAppVersionAndShowNotification(@NonNull final Application application,
- final String versionName,
- final String apkLocationUrl,
- final int versionCode) {
- if (BuildConfig.VERSION_CODE >= versionCode) {
- return;
- }
-
- // A pending intent to open the apk location url in the browser.
- final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkLocationUrl));
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
- final PendingIntent pendingIntent
- = PendingIntent.getActivity(application, 0, intent, 0);
-
- final String channelId = application
- .getString(R.string.app_update_notification_channel_id);
- final NotificationCompat.Builder notificationBuilder
- = new NotificationCompat.Builder(application, channelId)
- .setSmallIcon(R.drawable.ic_newpipe_update)
- .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
- .setContentIntent(pendingIntent)
- .setAutoCancel(true)
- .setContentTitle(application
- .getString(R.string.app_update_notification_content_title))
- .setContentText(application
- .getString(R.string.app_update_notification_content_text)
- + " " + versionName);
-
- final NotificationManagerCompat notificationManager
- = NotificationManagerCompat.from(application);
- notificationManager.notify(2000, notificationBuilder.build());
- }
-
- public static boolean isReleaseApk(@NonNull final App app) {
- return getCertificateSHA1Fingerprint(app).equals(RELEASE_CERT_PUBLIC_KEY_SHA1);
- }
-
- private void checkNewVersion() throws IOException, ReCaptchaException {
- final App app = App.getApp();
-
- final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
- final NewVersionManager manager = new NewVersionManager();
-
- // Check if the current apk is a github one or not.
- if (!isReleaseApk(app)) {
- return;
- }
-
- // Check if the last request has happened a certain time ago
- // to reduce the number of API requests.
- final long expiry = prefs.getLong(app.getString(R.string.update_expiry_key), 0);
- if (!manager.isExpired(expiry)) {
- return;
- }
-
- // Make a network request to get latest NewPipe data.
- final Response response = DownloaderImpl.getInstance().get(NEWPIPE_API_URL);
- handleResponse(response, manager, prefs, app);
- }
-
- private void handleResponse(@NonNull final Response response,
- @NonNull final NewVersionManager manager,
- @NonNull final SharedPreferences prefs,
- @NonNull final App app) {
- try {
- // Store a timestamp which needs to be exceeded,
- // before a new request to the API is made.
- final long newExpiry = manager
- .coerceExpiry(response.getHeader("expires"));
- prefs.edit()
- .putLong(app.getString(R.string.update_expiry_key), newExpiry)
- .apply();
- } catch (final Exception e) {
- if (DEBUG) {
- Log.w(TAG, "Could not extract and save new expiry date", e);
- }
- }
-
- // Parse the json from the response.
- try {
-
- final JsonObject githubStableObject = JsonParser.object()
- .from(response.responseBody()).getObject("flavors")
- .getObject("github").getObject("stable");
-
- final String versionName = githubStableObject
- .getString("version");
- final int versionCode = githubStableObject
- .getInt("version_code");
- final String apkLocationUrl = githubStableObject
- .getString("apk");
-
- compareAppVersionAndShowNotification(app, versionName,
- apkLocationUrl, versionCode);
- } catch (final JsonParserException e) {
- // Most likely something is wrong in data received from NEWPIPE_API_URL.
- // Do not alarm user and fail silently.
- if (DEBUG) {
- Log.w(TAG, "Could not get NewPipe API: invalid json", e);
- }
- }
- }
-
- /**
- * Start a new service which
- * checks if all conditions for performing a version check are met,
- * fetches the API endpoint {@link #NEWPIPE_API_URL} containing info
- * about the latest NewPipe version
- * and displays a notification about ana available update.
- *
- * Following conditions need to be met, before data is request from the server:
- *
- *
The app is signed with the correct signing key (by TeamNewPipe / schabi).
- * If the signing key differs from the one used upstream, the update cannot be installed.
- *
The user enabled searching for and notifying about updates in the settings.
- *
The app did not recently check for updates.
- * We do not want to make unnecessary connections and DOS our servers.
- *
- * Must not be executed when the app is in background.
- */
- public static void startNewVersionCheckService() {
- final Intent intent = new Intent(App.getApp().getApplicationContext(),
- CheckForNewAppVersion.class);
- App.getApp().startService(intent);
- }
-
- @Override
- protected void onHandleIntent(@Nullable final Intent intent) {
- try {
- checkNewVersion();
- } catch (final IOException e) {
- Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e);
- } catch (final ReCaptchaException e) {
- Log.e(TAG, "ReCaptchaException should never happen here.", e);
- }
-
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java
index 95663ea0a..fcb9d9725 100644
--- a/app/src/main/java/org/schabi/newpipe/MainActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java
@@ -20,7 +20,6 @@
package org.schabi.newpipe;
-import static org.schabi.newpipe.CheckForNewAppVersion.startNewVersionCheckService;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.content.BroadcastReceiver;
@@ -72,6 +71,7 @@ import org.schabi.newpipe.fragments.BackPressable;
import org.schabi.newpipe.fragments.MainFragment;
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
import org.schabi.newpipe.fragments.list.search.SearchFragment;
+import org.schabi.newpipe.local.feed.notifications.NotificationWorker;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.event.OnKeyDownListener;
import org.schabi.newpipe.player.helper.PlayerHolder;
@@ -159,11 +159,14 @@ public class MainActivity extends AppCompatActivity {
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(this, "Setting up drawer", e);
}
-
if (DeviceUtils.isTv(this)) {
FocusOverlayView.setupFocusObserver(this);
}
openMiniPlayerUponPlayerStarted();
+
+ // Schedule worker for checking for new streams and creating corresponding notifications
+ // if this is enabled by the user.
+ NotificationWorker.initialize(this);
}
@Override
@@ -174,10 +177,9 @@ public class MainActivity extends AppCompatActivity {
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
if (prefs.getBoolean(app.getString(R.string.update_app_key), true)) {
- // Start the service which is checking all conditions
+ // Start the worker which is checking all conditions
// and eventually searching for a new version.
- // The service searching for a new NewPipe version must not be started in background.
- startNewVersionCheckService();
+ NewVersionWorker.enqueueNewVersionCheckingWork(app);
}
}
@@ -227,7 +229,7 @@ public class MainActivity extends AppCompatActivity {
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_tabs_group, kioskId, 0, KioskTranslator
.getTranslatedKioskName(ks, this))
- .setIcon(KioskTranslator.getKioskIcon(ks, this));
+ .setIcon(KioskTranslator.getKioskIcon(ks));
kioskId++;
}
@@ -719,7 +721,7 @@ public class MainActivity extends AppCompatActivity {
if (toggle != null) {
toggle.syncState();
toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> mainBinding.getRoot()
- .openDrawer(GravityCompat.START));
+ .open());
mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED);
}
} else {
diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java
index 36bd6ee0d..402d4648d 100644
--- a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java
+++ b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java
@@ -1,5 +1,11 @@
package org.schabi.newpipe;
+import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
+import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
+import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
+import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
+import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5;
+
import android.content.Context;
import android.database.Cursor;
@@ -8,11 +14,6 @@ import androidx.room.Room;
import org.schabi.newpipe.database.AppDatabase;
-import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
-import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
-import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
-import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
-
public final class NewPipeDatabase {
private static volatile AppDatabase databaseInstance;
@@ -23,7 +24,7 @@ public final class NewPipeDatabase {
private static AppDatabase getDatabase(final Context context) {
return Room
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
- .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
+ .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
.build();
}
diff --git a/app/src/main/java/org/schabi/newpipe/NewVersionManager.kt b/app/src/main/java/org/schabi/newpipe/NewVersionManager.kt
deleted file mode 100644
index 36de1ecfc..000000000
--- a/app/src/main/java/org/schabi/newpipe/NewVersionManager.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package org.schabi.newpipe
-
-import java.time.Instant
-import java.time.ZonedDateTime
-import java.time.format.DateTimeFormatter
-
-class NewVersionManager {
-
- fun isExpired(expiry: Long): Boolean {
- return Instant.ofEpochSecond(expiry).isBefore(Instant.now())
- }
-
- /**
- * Coerce expiry date time in between 6 hours and 72 hours from now
- *
- * @return Epoch second of expiry date time
- */
- fun coerceExpiry(expiryString: String?): Long {
- val now = ZonedDateTime.now()
- return expiryString?.let {
-
- var expiry = ZonedDateTime.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(expiryString))
- expiry = maxOf(expiry, now.plusHours(6))
- expiry = minOf(expiry, now.plusHours(72))
- expiry.toEpochSecond()
- } ?: now.plusHours(6).toEpochSecond()
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt b/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt
new file mode 100644
index 000000000..060114974
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt
@@ -0,0 +1,163 @@
+package org.schabi.newpipe
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.edit
+import androidx.core.net.toUri
+import androidx.preference.PreferenceManager
+import androidx.work.OneTimeWorkRequest
+import androidx.work.WorkManager
+import androidx.work.WorkRequest
+import androidx.work.Worker
+import androidx.work.WorkerParameters
+import com.grack.nanojson.JsonParser
+import com.grack.nanojson.JsonParserException
+import org.schabi.newpipe.extractor.downloader.Response
+import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
+import org.schabi.newpipe.util.ReleaseVersionUtil.coerceUpdateCheckExpiry
+import org.schabi.newpipe.util.ReleaseVersionUtil.isLastUpdateCheckExpired
+import org.schabi.newpipe.util.ReleaseVersionUtil.isReleaseApk
+import java.io.IOException
+
+class NewVersionWorker(
+ context: Context,
+ workerParams: WorkerParameters
+) : Worker(context, workerParams) {
+
+ /**
+ * Method to compare the current and latest available app version.
+ * If a newer version is available, we show the update notification.
+ *
+ * @param versionName Name of new version
+ * @param apkLocationUrl Url with the new apk
+ * @param versionCode Code of new version
+ */
+ private fun compareAppVersionAndShowNotification(
+ versionName: String,
+ apkLocationUrl: String?,
+ versionCode: Int
+ ) {
+ if (BuildConfig.VERSION_CODE >= versionCode) {
+ return
+ }
+ val app = App.getApp()
+
+ // A pending intent to open the apk location url in the browser.
+ val intent = Intent(Intent.ACTION_VIEW, apkLocationUrl?.toUri())
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ val pendingIntent = PendingIntent.getActivity(app, 0, intent, 0)
+ val channelId = app.getString(R.string.app_update_notification_channel_id)
+ val notificationBuilder = NotificationCompat.Builder(app, channelId)
+ .setSmallIcon(R.drawable.ic_newpipe_update)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .setContentIntent(pendingIntent)
+ .setAutoCancel(true)
+ .setContentTitle(app.getString(R.string.app_update_notification_content_title))
+ .setContentText(
+ app.getString(R.string.app_update_notification_content_text) +
+ " " + versionName
+ )
+ val notificationManager = NotificationManagerCompat.from(app)
+ notificationManager.notify(2000, notificationBuilder.build())
+ }
+
+ @Throws(IOException::class, ReCaptchaException::class)
+ private fun checkNewVersion() {
+ // Check if the current apk is a github one or not.
+ if (!isReleaseApk()) {
+ return
+ }
+
+ val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
+ // Check if the last request has happened a certain time ago
+ // to reduce the number of API requests.
+ val expiry = prefs.getLong(applicationContext.getString(R.string.update_expiry_key), 0)
+ if (!isLastUpdateCheckExpired(expiry)) {
+ return
+ }
+
+ // Make a network request to get latest NewPipe data.
+ val response = DownloaderImpl.getInstance().get(NEWPIPE_API_URL)
+ handleResponse(response)
+ }
+
+ private fun handleResponse(response: Response) {
+ val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
+ try {
+ // Store a timestamp which needs to be exceeded,
+ // before a new request to the API is made.
+ val newExpiry = coerceUpdateCheckExpiry(response.getHeader("expires"))
+ prefs.edit {
+ putLong(applicationContext.getString(R.string.update_expiry_key), newExpiry)
+ }
+ } catch (e: Exception) {
+ if (DEBUG) {
+ Log.w(TAG, "Could not extract and save new expiry date", e)
+ }
+ }
+
+ // Parse the json from the response.
+ try {
+ val githubStableObject = JsonParser.`object`()
+ .from(response.responseBody()).getObject("flavors")
+ .getObject("github").getObject("stable")
+
+ val versionName = githubStableObject.getString("version")
+ val versionCode = githubStableObject.getInt("version_code")
+ val apkLocationUrl = githubStableObject.getString("apk")
+ compareAppVersionAndShowNotification(versionName, apkLocationUrl, versionCode)
+ } catch (e: JsonParserException) {
+ // Most likely something is wrong in data received from NEWPIPE_API_URL.
+ // Do not alarm user and fail silently.
+ if (DEBUG) {
+ Log.w(TAG, "Could not get NewPipe API: invalid json", e)
+ }
+ }
+ }
+
+ override fun doWork(): Result {
+ try {
+ checkNewVersion()
+ } catch (e: IOException) {
+ Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e)
+ return Result.failure()
+ } catch (e: ReCaptchaException) {
+ Log.e(TAG, "ReCaptchaException should never happen here.", e)
+ return Result.failure()
+ }
+ return Result.success()
+ }
+
+ companion object {
+ private val DEBUG = MainActivity.DEBUG
+ private val TAG = NewVersionWorker::class.java.simpleName
+ private const val NEWPIPE_API_URL = "https://newpipe.net/api/data.json"
+
+ /**
+ * Start a new worker which
+ * checks if all conditions for performing a version check are met,
+ * fetches the API endpoint [.NEWPIPE_API_URL] containing info
+ * about the latest NewPipe version
+ * and displays a notification about ana available update.
+ *
+ * Following conditions need to be met, before data is request from the server:
+ *
+ * * The app is signed with the correct signing key (by TeamNewPipe / schabi).
+ * If the signing key differs from the one used upstream, the update cannot be installed.
+ * * The user enabled searching for and notifying about updates in the settings.
+ * * The app did not recently check for updates.
+ * We do not want to make unnecessary connections and DOS our servers.
+ *
+ */
+ @JvmStatic
+ fun enqueueNewVersionCheckingWork(context: Context) {
+ val workRequest: WorkRequest =
+ OneTimeWorkRequest.Builder(NewVersionWorker::class.java).build()
+ WorkManager.getInstance(context).enqueue(workRequest)
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java b/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java
index fde006a60..c7604e512 100644
--- a/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java
+++ b/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java
@@ -14,7 +14,7 @@ import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.util.NavigationHelper;
-import org.schabi.newpipe.util.SaveUploaderUrlHelper;
+import org.schabi.newpipe.util.SparseItemUtil;
import java.util.Collections;
@@ -62,7 +62,8 @@ public final class QueueItemMenuUtil {
return true;
case R.id.menu_item_channel_details:
- SaveUploaderUrlHelper.saveUploaderUrlIfNeeded(context, item,
+ SparseItemUtil.fetchUploaderUrlIfSparse(context, item.getServiceId(),
+ item.getUrl(), item.getUploaderUrl(),
// An intent must be used here.
// Opening with FragmentManager transactions is not working,
// as PlayQueueActivity doesn't use fragments.
diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java
index 9d6e44f04..adef3c0e4 100644
--- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java
@@ -633,7 +633,7 @@ public class RouterActivity extends AppCompatActivity {
.subscribe(result -> {
final List sortedVideoStreams = ListHelper
.getSortedStreamVideosList(this, result.getVideoStreams(),
- result.getVideoOnlyStreams(), false);
+ result.getVideoOnlyStreams(), false, false);
final int selectedVideoStreamIndex = ListHelper
.getDefaultResolutionIndex(this, sortedVideoStreams);
diff --git a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt
index 1e5bd8799..50a3984e3 100644
--- a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt
+++ b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt
@@ -10,7 +10,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
-import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.R
@@ -21,30 +20,28 @@ import org.schabi.newpipe.util.ThemeHelper
import org.schabi.newpipe.util.external_communication.ShareUtils
class AboutActivity : AppCompatActivity() {
+
override fun onCreate(savedInstanceState: Bundle?) {
Localization.assureCorrectAppLanguage(this)
super.onCreate(savedInstanceState)
ThemeHelper.setTheme(this)
title = getString(R.string.title_activity_about)
+
val aboutBinding = ActivityAboutBinding.inflate(layoutInflater)
setContentView(aboutBinding.root)
setSupportActionBar(aboutBinding.aboutToolbar)
- supportActionBar!!.setDisplayHomeAsUpEnabled(true)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+
// Create the adapter that will return a fragment for each of the three
// primary sections of the activity.
val mAboutStateAdapter = AboutStateAdapter(this)
-
// Set up the ViewPager with the sections adapter.
aboutBinding.aboutViewPager2.adapter = mAboutStateAdapter
TabLayoutMediator(
aboutBinding.aboutTabLayout,
aboutBinding.aboutViewPager2
- ) { tab: TabLayout.Tab, position: Int ->
- when (position) {
- POS_ABOUT -> tab.setText(R.string.tab_about)
- POS_LICENSE -> tab.setText(R.string.tab_licenses)
- else -> throw IllegalArgumentException("Unknown position for ViewPager2")
- }
+ ) { tab, position ->
+ tab.setText(mAboutStateAdapter.getPageTitle(position))
}.attach()
}
@@ -75,13 +72,14 @@ class AboutActivity : AppCompatActivity() {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
- val aboutBinding = FragmentAboutBinding.inflate(inflater, container, false)
- aboutBinding.aboutAppVersion.text = BuildConfig.VERSION_NAME
- aboutBinding.aboutGithubLink.openLink(R.string.github_url)
- aboutBinding.aboutDonationLink.openLink(R.string.donation_url)
- aboutBinding.aboutWebsiteLink.openLink(R.string.website_url)
- aboutBinding.aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url)
- return aboutBinding.root
+ FragmentAboutBinding.inflate(inflater, container, false).apply {
+ aboutAppVersion.text = BuildConfig.VERSION_NAME
+ aboutGithubLink.openLink(R.string.github_url)
+ aboutDonationLink.openLink(R.string.donation_url)
+ aboutWebsiteLink.openLink(R.string.website_url)
+ aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url)
+ return root
+ }
}
}
@@ -90,17 +88,29 @@ class AboutActivity : AppCompatActivity() {
* one of the sections/tabs/pages.
*/
private class AboutStateAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
+ private val posAbout = 0
+ private val posLicense = 1
+ private val totalCount = 2
+
override fun createFragment(position: Int): Fragment {
return when (position) {
- POS_ABOUT -> AboutFragment()
- POS_LICENSE -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS)
+ posAbout -> AboutFragment()
+ posLicense -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS)
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
}
}
override fun getItemCount(): Int {
// Show 2 total pages.
- return TOTAL_COUNT
+ return totalCount
+ }
+
+ fun getPageTitle(position: Int): Int {
+ return when (position) {
+ posAbout -> R.string.tab_about
+ posLicense -> R.string.tab_licenses
+ else -> throw IllegalArgumentException("Unknown position for ViewPager2")
+ }
}
}
@@ -117,10 +127,6 @@ class AboutActivity : AppCompatActivity() {
"AndroidX", "2005 - 2011", "The Android Open Source Project",
"https://developer.android.com/jetpack", StandardLicenses.APACHE2
),
- SoftwareComponent(
- "CircleImageView", "2014 - 2020", "Henning Dodenhof",
- "https://github.com/hdodenhof/CircleImageView", StandardLicenses.APACHE2
- ),
SoftwareComponent(
"ExoPlayer", "2014 - 2020", "Google, Inc.",
"https://github.com/google/ExoPlayer", StandardLicenses.APACHE2
@@ -191,8 +197,5 @@ class AboutActivity : AppCompatActivity() {
"https://github.com/ByteHamster/SearchPreference", StandardLicenses.MIT
),
)
- private const val POS_ABOUT = 0
- private const val POS_LICENSE = 1
- private const val TOTAL_COUNT = 2
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt
index a04de8abc..c1dd38389 100644
--- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt
+++ b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt
@@ -87,60 +87,50 @@ object LicenseFragmentHelper {
return context.getString(color).substring(3)
}
- @JvmStatic
fun showLicense(context: Context?, license: License): Disposable {
+ return showLicense(context, license) { alertDialog ->
+ alertDialog.setPositiveButton(R.string.ok) { dialog, _ ->
+ dialog.dismiss()
+ }
+ }
+ }
+
+ fun showLicense(context: Context?, component: SoftwareComponent): Disposable {
+ return showLicense(context, component.license) { alertDialog ->
+ alertDialog.setPositiveButton(R.string.dismiss) { dialog, _ ->
+ dialog.dismiss()
+ }
+ alertDialog.setNeutralButton(R.string.open_website_license) { _, _ ->
+ ShareUtils.openUrlInBrowser(context!!, component.link)
+ }
+ }
+ }
+
+ private fun showLicense(
+ context: Context?,
+ license: License,
+ block: (AlertDialog.Builder) -> Unit
+ ): Disposable {
return if (context == null) {
Disposable.empty()
} else {
Observable.fromCallable { getFormattedLicense(context, license) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
- .subscribe { formattedLicense: String ->
+ .subscribe { formattedLicense ->
val webViewData = Base64.encodeToString(
- formattedLicense
- .toByteArray(StandardCharsets.UTF_8),
- Base64.NO_PADDING
+ formattedLicense.toByteArray(StandardCharsets.UTF_8), Base64.NO_PADDING
)
val webView = WebView(context)
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
- val alert = AlertDialog.Builder(context)
- alert.setTitle(license.name)
- alert.setView(webView)
- Localization.assureCorrectAppLanguage(context)
- alert.setNegativeButton(
- context.getString(R.string.ok)
- ) { dialog, _ -> dialog.dismiss() }
- alert.show()
- }
- }
- }
- @JvmStatic
- fun showLicense(context: Context?, component: SoftwareComponent): Disposable {
- return if (context == null) {
- Disposable.empty()
- } else {
- Observable.fromCallable { getFormattedLicense(context, component.license) }
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe { formattedLicense: String ->
- val webViewData = Base64.encodeToString(
- formattedLicense
- .toByteArray(StandardCharsets.UTF_8),
- Base64.NO_PADDING
- )
- val webView = WebView(context)
- webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
- val alert = AlertDialog.Builder(context)
- alert.setTitle(component.license.name)
- alert.setView(webView)
- Localization.assureCorrectAppLanguage(context)
- alert.setPositiveButton(
- R.string.dismiss
- ) { dialog, _ -> dialog.dismiss() }
- alert.setNeutralButton(R.string.open_website_license) { _, _ ->
- ShareUtils.openUrlInBrowser(context, component.link)
+
+ AlertDialog.Builder(context).apply {
+ setTitle(license.name)
+ setView(webView)
+ Localization.assureCorrectAppLanguage(context)
+ block(this)
+ show()
}
- alert.show()
}
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java
index cf52d9453..28ddc8184 100644
--- a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java
+++ b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java
@@ -1,5 +1,7 @@
package org.schabi.newpipe.database;
+import static org.schabi.newpipe.database.Migrations.DB_VER_5;
+
import androidx.room.Database;
import androidx.room.RoomDatabase;
import androidx.room.TypeConverters;
@@ -27,8 +29,6 @@ import org.schabi.newpipe.database.stream.model.StreamStateEntity;
import org.schabi.newpipe.database.subscription.SubscriptionDAO;
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
-import static org.schabi.newpipe.database.Migrations.DB_VER_4;
-
@TypeConverters({Converters.class})
@Database(
entities = {
@@ -38,7 +38,7 @@ import static org.schabi.newpipe.database.Migrations.DB_VER_4;
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
FeedLastUpdatedEntity.class
},
- version = DB_VER_4
+ version = DB_VER_5
)
public abstract class AppDatabase extends RoomDatabase {
public static final String DATABASE_NAME = "newpipe.db";
diff --git a/app/src/main/java/org/schabi/newpipe/database/Migrations.java b/app/src/main/java/org/schabi/newpipe/database/Migrations.java
index fdd38a824..7de08442c 100644
--- a/app/src/main/java/org/schabi/newpipe/database/Migrations.java
+++ b/app/src/main/java/org/schabi/newpipe/database/Migrations.java
@@ -22,6 +22,7 @@ public final class Migrations {
public static final int DB_VER_2 = 2;
public static final int DB_VER_3 = 3;
public static final int DB_VER_4 = 4;
+ public static final int DB_VER_5 = 5;
private static final String TAG = Migrations.class.getName();
public static final boolean DEBUG = MainActivity.DEBUG;
@@ -179,5 +180,14 @@ public final class Migrations {
}
};
- private Migrations() { }
+ public static final Migration MIGRATION_4_5 = new Migration(DB_VER_4, DB_VER_5) {
+ @Override
+ public void migrate(@NonNull final SupportSQLiteDatabase database) {
+ database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` "
+ + "INTEGER NOT NULL DEFAULT 0");
+ }
+ };
+
+ private Migrations() {
+ }
}
diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt
index 72692a9f5..d573788a6 100644
--- a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt
+++ b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt
@@ -12,6 +12,7 @@ import org.schabi.newpipe.database.feed.model.FeedEntity
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
import org.schabi.newpipe.database.stream.StreamWithState
import org.schabi.newpipe.database.stream.model.StreamStateEntity
+import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import java.time.OffsetDateTime
@@ -252,4 +253,21 @@ abstract class FeedDAO {
"""
)
abstract fun getAllOutdatedForGroup(groupId: Long, outdatedThreshold: OffsetDateTime): Flowable>
+
+ @Query(
+ """
+ SELECT s.* FROM subscriptions s
+
+ LEFT JOIN feed_last_updated lu
+ ON s.uid = lu.subscription_id
+
+ WHERE
+ (lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold)
+ AND s.notification_mode = :notificationMode
+ """
+ )
+ abstract fun getOutdatedWithNotificationMode(
+ outdatedThreshold: OffsetDateTime,
+ @NotificationMode notificationMode: Int
+ ): Flowable>
}
diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java
index 0a765ed4e..150d4a8e5 100644
--- a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java
+++ b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java
@@ -3,6 +3,7 @@ package org.schabi.newpipe.database.history.dao;
import androidx.annotation.Nullable;
import androidx.room.Dao;
import androidx.room.Query;
+import androidx.room.RewriteQueriesToDropUnusedColumns;
import org.schabi.newpipe.database.history.model.StreamHistoryEntity;
import org.schabi.newpipe.database.history.model.StreamHistoryEntry;
@@ -67,6 +68,7 @@ public abstract class StreamHistoryDAO implements HistoryDAO {
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
Flowable getMaximumIndexOf(long playlistId);
+ @RewriteQueriesToDropUnusedColumns
@Transaction
@Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN "
// get ids of streams of the given playlist
diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt
index 7dc16e784..a22fd2bb9 100644
--- a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt
+++ b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt
@@ -39,6 +39,9 @@ abstract class StreamDAO : BasicDAO {
@Insert(onConflict = OnConflictStrategy.IGNORE)
internal abstract fun silentInsertAllInternal(streams: List): List
+ @Query("SELECT COUNT(*) != 0 FROM streams WHERE url = :url AND service_id = :serviceId")
+ internal abstract fun exists(serviceId: Int, url: String): Boolean
+
@Query(
"""
SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration
diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java b/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java
new file mode 100644
index 000000000..07e0eb7d3
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java
@@ -0,0 +1,14 @@
+package org.schabi.newpipe.database.subscription;
+
+import androidx.annotation.IntDef;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+@IntDef({NotificationMode.DISABLED, NotificationMode.ENABLED})
+@Retention(RetentionPolicy.SOURCE)
+public @interface NotificationMode {
+
+ int DISABLED = 0;
+ int ENABLED = 1;
+ //other values reserved for the future
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt
index 9798ec72d..47b6f4dd9 100644
--- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt
+++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
+import androidx.room.RewriteQueriesToDropUnusedColumns
import androidx.room.Transaction
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
@@ -31,6 +32,7 @@ abstract class SubscriptionDAO : BasicDAO {
)
abstract fun getSubscriptionsFiltered(filter: String): Flowable>
+ @RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT * FROM subscriptions s
@@ -47,6 +49,7 @@ abstract class SubscriptionDAO : BasicDAO {
currentGroupId: Long
): Flowable>
+ @RewriteQueriesToDropUnusedColumns
@Query(
"""
SELECT * FROM subscriptions s
diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java
index 1cf38dbca..0e4bda490 100644
--- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java
+++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java
@@ -26,6 +26,7 @@ public class SubscriptionEntity {
public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url";
public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
public static final String SUBSCRIPTION_DESCRIPTION = "description";
+ public static final String SUBSCRIPTION_NOTIFICATION_MODE = "notification_mode";
@PrimaryKey(autoGenerate = true)
private long uid = 0;
@@ -48,6 +49,9 @@ public class SubscriptionEntity {
@ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
private String description;
+ @ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE)
+ private int notificationMode;
+
@Ignore
public static SubscriptionEntity from(@NonNull final ChannelInfo info) {
final SubscriptionEntity result = new SubscriptionEntity();
@@ -114,6 +118,15 @@ public class SubscriptionEntity {
this.description = description;
}
+ @NotificationMode
+ public int getNotificationMode() {
+ return notificationMode;
+ }
+
+ public void setNotificationMode(@NotificationMode final int notificationMode) {
+ this.notificationMode = notificationMode;
+ }
+
@Ignore
public void setData(final String n, final String au, final String d, final Long sc) {
this.setName(n);
diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
index 5c954ad64..f5c226908 100644
--- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
@@ -61,6 +61,7 @@ import org.schabi.newpipe.util.FilenameUtils;
import org.schabi.newpipe.util.ListHelper;
import org.schabi.newpipe.util.PermissionHelper;
import org.schabi.newpipe.util.SecondaryStreamHelper;
+import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
import org.schabi.newpipe.util.StreamItemAdapter;
import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper;
import org.schabi.newpipe.util.ThemeHelper;
@@ -151,7 +152,7 @@ public class DownloadDialog extends DialogFragment
public static DownloadDialog newInstance(final Context context, final StreamInfo info) {
final ArrayList streamsList = new ArrayList<>(ListHelper
.getSortedStreamVideosList(context, info.getVideoStreams(),
- info.getVideoOnlyStreams(), false));
+ info.getVideoOnlyStreams(), false, false));
final int selectedStreamIndex = ListHelper.getDefaultResolutionIndex(context, streamsList);
final DownloadDialog instance = newInstance(info);
@@ -321,21 +322,15 @@ public class DownloadDialog extends DialogFragment
final int threads = prefs.getInt(getString(R.string.default_download_threads), 3);
dialogBinding.threadsCount.setText(String.valueOf(threads));
dialogBinding.threads.setProgress(threads - 1);
- dialogBinding.threads.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
+ dialogBinding.threads.setOnSeekBarChangeListener(new SimpleOnSeekBarChangeListener() {
@Override
- public void onProgressChanged(final SeekBar seekbar, final int progress,
+ public void onProgressChanged(@NonNull final SeekBar seekbar, final int progress,
final boolean fromUser) {
final int newProgress = progress + 1;
prefs.edit().putInt(getString(R.string.default_download_threads), newProgress)
.apply();
dialogBinding.threadsCount.setText(String.valueOf(newProgress));
}
-
- @Override
- public void onStartTrackingTouch(final SeekBar p1) { }
-
- @Override
- public void onStopTrackingTouch(final SeekBar p1) { }
});
fetchStreamsSize();
diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt
index 3fd743c69..e4dd2e16d 100644
--- a/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt
+++ b/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt
@@ -1,7 +1,6 @@
package org.schabi.newpipe.error
import android.app.Activity
-import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
@@ -10,7 +9,7 @@ import android.os.Build
import android.view.View
import android.widget.Toast
import androidx.core.app.NotificationCompat
-import androidx.core.content.ContextCompat
+import androidx.core.app.NotificationManagerCompat
import androidx.fragment.app.Fragment
import com.google.android.material.snackbar.Snackbar
import org.schabi.newpipe.R
@@ -105,13 +104,6 @@ class ErrorUtil {
*/
@JvmStatic
fun createNotification(context: Context, errorInfo: ErrorInfo) {
- val notificationManager =
- ContextCompat.getSystemService(context, NotificationManager::class.java)
- if (notificationManager == null) {
- // this should never happen, but just in case open error activity
- openActivity(context, errorInfo)
- }
-
var pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
pendingIntentFlags = pendingIntentFlags or PendingIntent.FLAG_IMMUTABLE
@@ -122,7 +114,13 @@ class ErrorUtil {
context,
context.getString(R.string.error_report_channel_id)
)
- .setSmallIcon(R.drawable.ic_bug_report)
+ .setSmallIcon(
+ // the vector drawable icon causes crashes on KitKat devices
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
+ R.drawable.ic_bug_report
+ else
+ android.R.drawable.stat_notify_error
+ )
.setContentTitle(context.getString(R.string.error_report_notification_title))
.setContentText(context.getString(errorInfo.messageStringId))
.setAutoCancel(true)
@@ -135,7 +133,8 @@ class ErrorUtil {
)
)
- notificationManager!!.notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build())
+ NotificationManagerCompat.from(context)
+ .notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build())
// since the notification is silent, also show a toast, otherwise the user is confused
Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT)
diff --git a/app/src/main/java/org/schabi/newpipe/error/UserAction.java b/app/src/main/java/org/schabi/newpipe/error/UserAction.java
index e8dec9556..976173373 100644
--- a/app/src/main/java/org/schabi/newpipe/error/UserAction.java
+++ b/app/src/main/java/org/schabi/newpipe/error/UserAction.java
@@ -26,10 +26,11 @@ public enum UserAction {
DOWNLOAD_OPEN_DIALOG("download open dialog"),
DOWNLOAD_POSTPROCESSING("download post-processing"),
DOWNLOAD_FAILED("download failed"),
+ NEW_STREAMS_NOTIFICATIONS("new streams notifications"),
PREFERENCES_MIGRATION("migration of preferences"),
SHARE_TO_NEWPIPE("share to newpipe"),
- CHECK_FOR_NEW_APP_VERSION("check for new app version");
-
+ CHECK_FOR_NEW_APP_VERSION("check for new app version"),
+ OPEN_INFO_ITEM_DIALOG("open info item dialog");
private final String message;
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java b/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java
index cbd44566e..6b17803c4 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java
@@ -1,5 +1,6 @@
package org.schabi.newpipe.fragments;
+import androidx.annotation.NonNull;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.StaggeredGridLayoutManager;
@@ -10,7 +11,7 @@ import androidx.recyclerview.widget.StaggeredGridLayoutManager;
*/
public abstract class OnScrollBelowItemsListener extends RecyclerView.OnScrollListener {
@Override
- public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) {
+ public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) {
super.onScrolled(recyclerView, dx, dy);
if (dy > 0) {
int pastVisibleItems = 0;
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java
index 2fe615764..5016a49f6 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java
@@ -1,5 +1,7 @@
package org.schabi.newpipe.fragments.detail;
+import androidx.annotation.NonNull;
+
import org.schabi.newpipe.player.playqueue.PlayQueue;
import java.io.Serializable;
@@ -46,6 +48,7 @@ class StackItem implements Serializable {
return playQueue;
}
+ @NonNull
@Override
public String toString() {
return getServiceId() + ":" + getUrl() + " > " + getTitle();
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
index 78f0bfffb..c57942aa5 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
@@ -43,7 +43,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.core.content.ContextCompat;
import androidx.preference.PreferenceManager;
-import com.google.android.exoplayer2.ExoPlaybackException;
+import com.google.android.exoplayer2.PlaybackException;
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.material.appbar.AppBarLayout;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
@@ -1617,6 +1617,7 @@ public final class VideoDetailFragment
activity,
info.getVideoStreams(),
info.getVideoOnlyStreams(),
+ false,
false);
selectedVideoStreamIndex = ListHelper
.getDefaultResolutionIndex(activity, sortedVideoStreams);
@@ -1883,9 +1884,8 @@ public final class VideoDetailFragment
}
@Override
- public void onPlayerError(final ExoPlaybackException error) {
- if (error.type == ExoPlaybackException.TYPE_SOURCE
- || error.type == ExoPlaybackException.TYPE_UNEXPECTED) {
+ public void onPlayerError(final PlaybackException error, final boolean isCatchableException) {
+ if (!isCatchableException) {
// Properly exit from fullscreen
toggleFullscreenIfInFullscreenMode();
hideMainPlayerOnLoadingNewStream();
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java
index 9309a8a49..ae704e88c 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java
@@ -15,6 +15,7 @@ import androidx.appcompat.app.AlertDialog;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlaybackException;
+import com.google.android.exoplayer2.PlaybackException;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
@@ -28,6 +29,10 @@ import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Supplier;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_DECODING_FAILED;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_UNSPECIFIED;
+
/**
* Outsourced logic for crashing the player in the {@link VideoDetailFragment}.
*/
@@ -51,7 +56,8 @@ public final class VideoDetailPlayerCrasher {
exceptionTypes.put(
"Source",
() -> ExoPlaybackException.createForSource(
- new IOException(defaultMsg)
+ new IOException(defaultMsg),
+ ERROR_CODE_BEHIND_LIVE_WINDOW
)
);
exceptionTypes.put(
@@ -61,13 +67,16 @@ public final class VideoDetailPlayerCrasher {
"Dummy renderer",
0,
null,
- C.FORMAT_HANDLED
+ C.FORMAT_HANDLED,
+ /*isRecoverable=*/false,
+ ERROR_CODE_DECODING_FAILED
)
);
exceptionTypes.put(
"Unexpected",
() -> ExoPlaybackException.createForUnexpected(
- new RuntimeException(defaultMsg)
+ new RuntimeException(defaultMsg),
+ ERROR_CODE_UNSPECIFIED
)
);
exceptionTypes.put(
@@ -139,7 +148,7 @@ public final class VideoDetailPlayerCrasher {
/**
* Note that this method does not crash the underlying exoplayer directly (it's not possible).
- * It simply supplies a Exception to {@link Player#onPlayerError(ExoPlaybackException)}.
+ * It simply supplies a Exception to {@link Player#onPlayerError(PlaybackException)}.
* @param player
* @param exception
*/
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java
index 6ea0a8a0d..27e5a8571 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java
@@ -1,6 +1,8 @@
package org.schabi.newpipe.fragments.list;
-import android.app.Activity;
+import static org.schabi.newpipe.ktx.ViewUtils.animate;
+import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
+
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Configuration;
@@ -25,29 +27,19 @@ import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
-import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.fragments.BaseStateFragment;
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
-import org.schabi.newpipe.info_list.InfoItemDialog;
+import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.InfoListAdapter;
-import org.schabi.newpipe.player.helper.PlayerHolder;
-import org.schabi.newpipe.util.external_communication.KoreUtils;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.util.StateSaver;
-import org.schabi.newpipe.util.StreamDialogEntry;
import org.schabi.newpipe.views.SuperScrollLayoutManager;
-import java.util.ArrayList;
-import java.util.Arrays;
import java.util.List;
import java.util.Queue;
import java.util.function.Supplier;
-import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
-import static org.schabi.newpipe.ktx.ViewUtils.animate;
-import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
-
public abstract class BaseListFragment extends BaseStateFragment
implements ListViewContract, StateSaver.WriteRead,
SharedPreferences.OnSharedPreferenceChangeListener {
@@ -268,11 +260,11 @@ public abstract class BaseListFragment extends BaseStateFragment
@Override
public void held(final StreamInfoItem selectedItem) {
- showStreamDialog(selectedItem);
+ showInfoItemDialog(selectedItem);
}
});
- infoListAdapter.setOnChannelSelectedListener(new OnClickGesture() {
+ infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<>() {
@Override
public void selected(final ChannelInfoItem selectedItem) {
try {
@@ -288,7 +280,7 @@ public abstract class BaseListFragment extends BaseStateFragment
}
});
- infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture() {
+ infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture<>() {
@Override
public void selected(final PlaylistInfoItem selectedItem) {
try {
@@ -350,7 +342,8 @@ public abstract class BaseListFragment extends BaseStateFragment
itemsList.clearOnScrollListeners();
itemsList.addOnScrollListener(new DefaultItemListOnScrolledDownListener() {
@Override
- public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) {
+ public void onScrolled(@NonNull final RecyclerView recyclerView,
+ final int dx, final int dy) {
super.onScrolled(recyclerView, dx, dy);
if (dy != 0) {
@@ -409,55 +402,12 @@ public abstract class BaseListFragment extends BaseStateFragment
}
}
- protected void showStreamDialog(final StreamInfoItem item) {
- final Context context = getContext();
- final Activity activity = getActivity();
- if (context == null || context.getResources() == null || activity == null) {
- return;
+ protected void showInfoItemDialog(final StreamInfoItem item) {
+ try {
+ new InfoItemDialog.Builder(getActivity(), getContext(), this, item).create().show();
+ } catch (final IllegalArgumentException e) {
+ InfoItemDialog.Builder.reportErrorDuringInitialization(e, item);
}
- final List entries = new ArrayList<>();
-
- if (PlayerHolder.getInstance().isPlayQueueReady()) {
- entries.add(StreamDialogEntry.enqueue);
-
- if (PlayerHolder.getInstance().getQueueSize() > 1) {
- entries.add(StreamDialogEntry.enqueue_next);
- }
- }
-
- if (item.getStreamType() == StreamType.AUDIO_STREAM) {
- entries.addAll(Arrays.asList(
- StreamDialogEntry.start_here_on_background,
- StreamDialogEntry.append_playlist,
- StreamDialogEntry.share
- ));
- } else {
- entries.addAll(Arrays.asList(
- StreamDialogEntry.start_here_on_background,
- StreamDialogEntry.start_here_on_popup,
- StreamDialogEntry.append_playlist,
- StreamDialogEntry.share
- ));
- }
- entries.add(StreamDialogEntry.open_in_browser);
- if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) {
- entries.add(StreamDialogEntry.play_with_kodi);
- }
-
- // show "mark as watched" only when watch history is enabled
- if (StreamDialogEntry.shouldAddMarkAsWatched(item.getStreamType(), context)) {
- entries.add(
- StreamDialogEntry.mark_as_watched
- );
- }
- if (!isNullOrEmpty(item.getUploaderUrl())) {
- entries.add(StreamDialogEntry.show_channel_details);
- }
-
- StreamDialogEntry.setEnabledEntries(entries);
-
- new InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context),
- (dialog, which) -> StreamDialogEntry.clickOn(which, this, item)).show();
}
/*//////////////////////////////////////////////////////////////////////////
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java
index ebd586e35..35424437d 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java
@@ -9,6 +9,7 @@ import androidx.annotation.NonNull;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction;
+import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.Page;
@@ -27,8 +28,8 @@ import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
-public abstract class BaseListInfoFragment
- extends BaseListFragment {
+public abstract class BaseListInfoFragment>
+ extends BaseListFragment> {
@State
protected int serviceId = Constants.NO_SERVICE_ID;
@State
@@ -37,7 +38,7 @@ public abstract class BaseListInfoFragment
protected String url;
private final UserAction errorUserAction;
- protected I currentInfo;
+ protected L currentInfo;
protected Page currentNextPage;
protected Disposable currentWorker;
@@ -97,7 +98,7 @@ public abstract class BaseListInfoFragment
@SuppressWarnings("unchecked")
public void readFrom(@NonNull final Queue
*/
-public class KioskFragment extends BaseListInfoFragment {
+public class KioskFragment extends BaseListInfoFragment {
@State
String kioskId = "";
String kioskTranslatedName;
@@ -145,7 +146,7 @@ public class KioskFragment extends BaseListInfoFragment {
}
@Override
- public Single loadMoreItemsLogic() {
+ public Single> loadMoreItemsLogic() {
return ExtractorHelper.getMoreKioskItems(serviceId, url, currentNextPage);
}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java
index 84dcb4fd9..5bf20c144 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java
@@ -1,11 +1,10 @@
package org.schabi.newpipe.fragments.list.playlist;
-import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
-import android.app.Activity;
import android.content.Context;
+import android.content.res.ColorStateList;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
@@ -19,6 +18,10 @@ import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
+import androidx.core.content.ContextCompat;
+
+import com.google.android.material.shape.CornerFamily;
+import com.google.android.material.shape.ShapeAppearanceModel;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
@@ -36,24 +39,20 @@ import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
-import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
-import org.schabi.newpipe.info_list.InfoItemDialog;
+import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
import org.schabi.newpipe.player.MainPlayer.PlayerType;
-import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PicassoHelper;
-import org.schabi.newpipe.util.StreamDialogEntry;
-import org.schabi.newpipe.util.external_communication.KoreUtils;
+import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
@@ -64,7 +63,7 @@ import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
-public class PlaylistFragment extends BaseListInfoFragment {
+public class PlaylistFragment extends BaseListInfoFragment {
private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG";
@@ -140,60 +139,22 @@ public class PlaylistFragment extends BaseListInfoFragment {
}
@Override
- protected void showStreamDialog(final StreamInfoItem item) {
+ protected void showInfoItemDialog(final StreamInfoItem item) {
final Context context = getContext();
- final Activity activity = getActivity();
- if (context == null || context.getResources() == null || activity == null) {
- return;
+ try {
+ final InfoItemDialog.Builder dialogBuilder =
+ new InfoItemDialog.Builder(getActivity(), context, this, item);
+
+ dialogBuilder
+ .setAction(
+ StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND,
+ (f, infoItem) -> NavigationHelper.playOnBackgroundPlayer(
+ context, getPlayQueueStartingAt(infoItem), true))
+ .create()
+ .show();
+ } catch (final IllegalArgumentException e) {
+ InfoItemDialog.Builder.reportErrorDuringInitialization(e, item);
}
-
- final ArrayList entries = new ArrayList<>();
-
- if (PlayerHolder.getInstance().isPlayQueueReady()) {
- entries.add(StreamDialogEntry.enqueue);
-
- if (PlayerHolder.getInstance().getQueueSize() > 1) {
- entries.add(StreamDialogEntry.enqueue_next);
- }
- }
-
- if (item.getStreamType() == StreamType.AUDIO_STREAM) {
- entries.addAll(Arrays.asList(
- StreamDialogEntry.start_here_on_background,
- StreamDialogEntry.append_playlist,
- StreamDialogEntry.share
- ));
- } else {
- entries.addAll(Arrays.asList(
- StreamDialogEntry.start_here_on_background,
- StreamDialogEntry.start_here_on_popup,
- StreamDialogEntry.append_playlist,
- StreamDialogEntry.share
- ));
- }
- entries.add(StreamDialogEntry.open_in_browser);
- if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) {
- entries.add(StreamDialogEntry.play_with_kodi);
- }
-
- // show "mark as watched" only when watch history is enabled
- if (StreamDialogEntry.shouldAddMarkAsWatched(item.getStreamType(), context)) {
- entries.add(
- StreamDialogEntry.mark_as_watched
- );
- }
- if (!isNullOrEmpty(item.getUploaderUrl())) {
- entries.add(StreamDialogEntry.show_channel_details);
- }
-
- StreamDialogEntry.setEnabledEntries(entries);
-
- StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItem) ->
- NavigationHelper.playOnBackgroundPlayer(context,
- getPlayQueueStartingAt(infoItem), true));
-
- new InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context),
- (dialog, which) -> StreamDialogEntry.clickOn(which, this, item)).show();
}
@Override
@@ -249,7 +210,7 @@ public class PlaylistFragment extends BaseListInfoFragment {
//////////////////////////////////////////////////////////////////////////*/
@Override
- protected Single loadMoreItemsLogic() {
+ protected Single> loadMoreItemsLogic() {
return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPage);
}
@@ -328,9 +289,14 @@ public class PlaylistFragment extends BaseListInfoFragment {
&& (YoutubeParsingHelper.isYoutubeMixId(result.getId())
|| YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) {
// this is an auto-generated playlist (e.g. Youtube mix), so a radio is shown
- headerBinding.uploaderAvatarView.setDisableCircularTransformation(true);
- headerBinding.uploaderAvatarView.setBorderColor(
- getResources().getColor(R.color.transparent_background_color));
+ final ShapeAppearanceModel model = ShapeAppearanceModel.builder()
+ .setAllCorners(CornerFamily.ROUNDED, 0f)
+ .build(); // this turns the image back into a square
+ headerBinding.uploaderAvatarView.setShapeAppearanceModel(model);
+ headerBinding.uploaderAvatarView.setStrokeColor(
+ ColorStateList.valueOf(ContextCompat.getColor(
+ requireContext(), R.color.transparent_background_color))
+ );
headerBinding.uploaderAvatarView.setImageDrawable(
AppCompatResources.getDrawable(requireContext(),
R.drawable.ic_radio)
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java
index 3cfcfd470..fb983b01e 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java
@@ -7,6 +7,7 @@ import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
+import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.R;
@@ -34,8 +35,10 @@ public class SuggestionListAdapter
this.listener = listener;
}
+ @NonNull
@Override
- public SuggestionItemHolder onCreateViewHolder(final ViewGroup parent, final int viewType) {
+ public SuggestionItemHolder onCreateViewHolder(@NonNull final ViewGroup parent,
+ final int viewType) {
return new SuggestionItemHolder(LayoutInflater.from(context)
.inflate(R.layout.item_search_suggestion, parent, false));
}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java
index 7ba6aa2ab..f0ece69f3 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java
@@ -15,6 +15,7 @@ import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.RelatedItemsHeaderBinding;
import org.schabi.newpipe.error.UserAction;
+import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
@@ -26,7 +27,7 @@ import java.util.function.Supplier;
import io.reactivex.rxjava3.core.Single;
-public class RelatedItemsFragment extends BaseListInfoFragment
+public class RelatedItemsFragment extends BaseListInfoFragment
implements SharedPreferences.OnSharedPreferenceChangeListener {
private static final String INFO_KEY = "related_info_key";
@@ -86,7 +87,7 @@ public class RelatedItemsFragment extends BaseListInfoFragment
}
@Override
- protected Single loadMoreItemsLogic() {
+ protected Single> loadMoreItemsLogic() {
return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage);
}
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemDialog.java
deleted file mode 100644
index c485337f0..000000000
--- a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemDialog.java
+++ /dev/null
@@ -1,54 +0,0 @@
-package org.schabi.newpipe.info_list;
-
-import android.app.Activity;
-import android.content.DialogInterface;
-import android.view.View;
-import android.widget.TextView;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appcompat.app.AlertDialog;
-
-import org.schabi.newpipe.R;
-import org.schabi.newpipe.extractor.stream.StreamInfoItem;
-
-public class InfoItemDialog {
- private final AlertDialog dialog;
-
- public InfoItemDialog(@NonNull final Activity activity,
- @NonNull final StreamInfoItem info,
- @NonNull final String[] commands,
- @NonNull final DialogInterface.OnClickListener actions) {
- this(activity, commands, actions, info.getName(), info.getUploaderName());
- }
-
- public InfoItemDialog(@NonNull final Activity activity,
- @NonNull final String[] commands,
- @NonNull final DialogInterface.OnClickListener actions,
- @NonNull final String title,
- @Nullable final String additionalDetail) {
-
- final View bannerView = View.inflate(activity, R.layout.dialog_title, null);
- bannerView.setSelected(true);
-
- final TextView titleView = bannerView.findViewById(R.id.itemTitleView);
- titleView.setText(title);
-
- final TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails);
- if (additionalDetail != null) {
- detailsView.setText(additionalDetail);
- detailsView.setVisibility(View.VISIBLE);
- } else {
- detailsView.setVisibility(View.GONE);
- }
-
- dialog = new AlertDialog.Builder(activity)
- .setCustomTitle(bannerView)
- .setItems(commands, actions)
- .create();
- }
-
- public void show() {
- dialog.show();
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java
new file mode 100644
index 000000000..5a266c0a8
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java
@@ -0,0 +1,356 @@
+package org.schabi.newpipe.info_list.dialog;
+
+import static org.schabi.newpipe.MainActivity.DEBUG;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.os.Build;
+import android.util.Log;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.preference.PreferenceManager;
+
+import org.schabi.newpipe.App;
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.error.ErrorInfo;
+import org.schabi.newpipe.error.ErrorUtil;
+import org.schabi.newpipe.error.UserAction;
+import org.schabi.newpipe.extractor.InfoItem;
+import org.schabi.newpipe.extractor.stream.StreamInfoItem;
+import org.schabi.newpipe.extractor.stream.StreamType;
+import org.schabi.newpipe.player.helper.PlayerHolder;
+import org.schabi.newpipe.util.external_communication.KoreUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+
+/**
+ * Dialog for a {@link StreamInfoItem}.
+ * The dialog's content are actions that can be performed on the {@link StreamInfoItem}.
+ * This dialog is mostly used for longpress context menus.
+ */
+public final class InfoItemDialog {
+ private static final String TAG = Build.class.getSimpleName();
+ /**
+ * Ideally, {@link InfoItemDialog} would extend {@link AlertDialog}.
+ * However, extending {@link AlertDialog} requires many additional lines
+ * and brings more complexity to this class, especially the constructor.
+ * To circumvent this, an {@link AlertDialog.Builder} is used in the constructor.
+ * Its result is stored in this class variable to allow access via the {@link #show()} method.
+ */
+ private final AlertDialog dialog;
+
+ private InfoItemDialog(@NonNull final Activity activity,
+ @NonNull final Fragment fragment,
+ @NonNull final StreamInfoItem info,
+ @NonNull final List entries) {
+
+ // Create the dialog's title
+ final View bannerView = View.inflate(activity, R.layout.dialog_title, null);
+ bannerView.setSelected(true);
+
+ final TextView titleView = bannerView.findViewById(R.id.itemTitleView);
+ titleView.setText(info.getName());
+
+ final TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails);
+ if (info.getUploaderName() != null) {
+ detailsView.setText(info.getUploaderName());
+ detailsView.setVisibility(View.VISIBLE);
+ } else {
+ detailsView.setVisibility(View.GONE);
+ }
+
+ // Get the entry's descriptions which are displayed in the dialog
+ final String[] items = entries.stream()
+ .map(entry -> entry.getString(activity)).toArray(String[]::new);
+
+ // Call an entry's action / onClick method when the entry is selected.
+ final DialogInterface.OnClickListener action = (d, index) ->
+ entries.get(index).action.onClick(fragment, info);
+
+ dialog = new AlertDialog.Builder(activity)
+ .setCustomTitle(bannerView)
+ .setItems(items, action)
+ .create();
+
+ }
+
+ public void show() {
+ dialog.show();
+ }
+
+ /**
+ *
Builder to generate a {@link InfoItemDialog} for a {@link StreamInfoItem}.
+ * Use {@link #addEntry(StreamDialogDefaultEntry)}
+ * and {@link #addAllEntries(StreamDialogDefaultEntry...)} to add options to the dialog.
+ *
+ * Custom actions for entries can be set using
+ * {@link #setAction(StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)}.
+ */
+ public static class Builder {
+ @NonNull private final Activity activity;
+ @NonNull private final Context context;
+ @NonNull private final StreamInfoItem infoItem;
+ @NonNull private final Fragment fragment;
+ @NonNull private final List entries = new ArrayList<>();
+ private final boolean addDefaultEntriesAutomatically;
+
+ /**
+ *
Create a {@link Builder builder} instance for a {@link StreamInfoItem}
+ * that automatically adds the some default entries
+ * at the top and bottom of the dialog.
+ * Please note that some entries are not added depending on the user's preferences,
+ * the item's {@link StreamType} and the current player state.
+ *
+ * @param activity
+ * @param context
+ * @param fragment
+ * @param infoItem the item for this dialog; all entries and their actions work with
+ * this {@link StreamInfoItem}
+ * @throws IllegalArgumentException if activity, context
+ * or resources is null
+ */
+ public Builder(final Activity activity,
+ final Context context,
+ @NonNull final Fragment fragment,
+ @NonNull final StreamInfoItem infoItem) {
+ this(activity, context, fragment, infoItem, true);
+ }
+
+ /**
+ *
Create an instance of this {@link Builder} for a {@link StreamInfoItem}.
+ *
If {@code addDefaultEntriesAutomatically} is set to {@code true},
+ * some default entries are added to the top and bottom of the dialog.
+ * Please note that some entries are not added depending on the user's preferences,
+ * the item's {@link StreamType} and the current player state.
+ *
+ * @param activity
+ * @param context
+ * @param fragment
+ * @param infoItem
+ * @param addDefaultEntriesAutomatically
+ * whether default entries added with {@link #addDefaultBeginningEntries()}
+ * and {@link #addDefaultEndEntries()} are added automatically when generating
+ * the {@link InfoItemDialog}.
+ *
+ * Entries added with {@link #addEntry(StreamDialogDefaultEntry)} and
+ * {@link #addAllEntries(StreamDialogDefaultEntry...)} are added in between.
+ * @throws IllegalArgumentException if activity, context
+ * or resources is null
+ */
+ public Builder(final Activity activity,
+ final Context context,
+ @NonNull final Fragment fragment,
+ @NonNull final StreamInfoItem infoItem,
+ final boolean addDefaultEntriesAutomatically) {
+ if (activity == null || context == null || context.getResources() == null) {
+ if (DEBUG) {
+ Log.d(TAG, "activity, context or resources is null: activity = "
+ + activity + ", context = " + context);
+ }
+ throw new IllegalArgumentException("activity, context or resources is null");
+ }
+ this.activity = activity;
+ this.context = context;
+ this.fragment = fragment;
+ this.infoItem = infoItem;
+ this.addDefaultEntriesAutomatically = addDefaultEntriesAutomatically;
+ if (addDefaultEntriesAutomatically) {
+ addDefaultBeginningEntries();
+ }
+ }
+
+ /**
+ * Adds a new entry and appends it to the current entry list.
+ * @param entry the entry to add
+ * @return the current {@link Builder} instance
+ */
+ public Builder addEntry(@NonNull final StreamDialogDefaultEntry entry) {
+ entries.add(entry.toStreamDialogEntry());
+ return this;
+ }
+
+ /**
+ * Adds new entries. These are appended to the current entry list.
+ * @param newEntries the entries to add
+ * @return the current {@link Builder} instance
+ */
+ public Builder addAllEntries(@NonNull final StreamDialogDefaultEntry... newEntries) {
+ Stream.of(newEntries).forEach(this::addEntry);
+ return this;
+ }
+
+ /**
+ *
Change an entries' action that is called when the entry is selected.
+ *
Warning: Only use this method when the entry has been already added.
+ * Changing the action of an entry which has not been added to the Builder yet
+ * does not have an effect.
+ * @param entry the entry to change
+ * @param action the action to perform when the entry is selected
+ * @return the current {@link Builder} instance
+ */
+ public Builder setAction(@NonNull final StreamDialogDefaultEntry entry,
+ @NonNull final StreamDialogEntry.StreamDialogEntryAction action) {
+ for (int i = 0; i < entries.size(); i++) {
+ if (entries.get(i).resource == entry.resource) {
+ entries.set(i, new StreamDialogEntry(entry.resource, action));
+ return this;
+ }
+ }
+ return this;
+ }
+
+ /**
+ * Adds {@link StreamDialogDefaultEntry#ENQUEUE} if the player is open and
+ * {@link StreamDialogDefaultEntry#ENQUEUE_NEXT} if there are multiple streams
+ * in the play queue.
+ * @return the current {@link Builder} instance
+ */
+ public Builder addEnqueueEntriesIfNeeded() {
+ if (PlayerHolder.getInstance().isPlayQueueReady()) {
+ addEntry(StreamDialogDefaultEntry.ENQUEUE);
+
+ if (PlayerHolder.getInstance().getQueueSize() > 1) {
+ addEntry(StreamDialogDefaultEntry.ENQUEUE_NEXT);
+ }
+ }
+ return this;
+ }
+
+ /**
+ * Adds the {@link StreamDialogDefaultEntry#START_HERE_ON_BACKGROUND}.
+ * If the {@link #infoItem} is not a pure audio (live) stream,
+ * {@link StreamDialogDefaultEntry#START_HERE_ON_POPUP} is added, too.
+ * @return the current {@link Builder} instance
+ */
+ public Builder addStartHereEntries() {
+ addEntry(StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND);
+ if (infoItem.getStreamType() != StreamType.AUDIO_STREAM
+ && infoItem.getStreamType() != StreamType.AUDIO_LIVE_STREAM) {
+ addEntry(StreamDialogDefaultEntry.START_HERE_ON_POPUP);
+ }
+ return this;
+ }
+
+ /**
+ * Adds {@link StreamDialogDefaultEntry#MARK_AS_WATCHED} if the watch history is enabled
+ * and the stream is not a livestream.
+ * @return the current {@link Builder} instance
+ */
+ public Builder addMarkAsWatchedEntryIfNeeded() {
+ final boolean isWatchHistoryEnabled = PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getBoolean(context.getString(R.string.enable_watch_history_key), false);
+ if (isWatchHistoryEnabled
+ && infoItem.getStreamType() != StreamType.LIVE_STREAM
+ && infoItem.getStreamType() != StreamType.AUDIO_LIVE_STREAM) {
+ addEntry(StreamDialogDefaultEntry.MARK_AS_WATCHED);
+ }
+ return this;
+ }
+
+ /**
+ * Adds the {@link StreamDialogDefaultEntry#PLAY_WITH_KODI} entry if it is needed.
+ * @return the current {@link Builder} instance
+ */
+ public Builder addPlayWithKodiEntryIfNeeded() {
+ if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
+ addEntry(StreamDialogDefaultEntry.PLAY_WITH_KODI);
+ }
+ return this;
+ }
+
+ /**
+ * Add the entries which are usually at the top of the action list.
+ *
+ * This method adds the "enqueue" (see {@link #addEnqueueEntriesIfNeeded()})
+ * and "start here" (see {@link #addStartHereEntries()} entries.
+ * @return the current {@link Builder} instance
+ */
+ public Builder addDefaultBeginningEntries() {
+ addEnqueueEntriesIfNeeded();
+ addStartHereEntries();
+ return this;
+ }
+
+ /**
+ * Add the entries which are usually at the bottom of the action list.
+ * @return the current {@link Builder} instance
+ */
+ public Builder addDefaultEndEntries() {
+ addAllEntries(
+ StreamDialogDefaultEntry.APPEND_PLAYLIST,
+ StreamDialogDefaultEntry.SHARE,
+ StreamDialogDefaultEntry.OPEN_IN_BROWSER
+ );
+ addPlayWithKodiEntryIfNeeded();
+ addMarkAsWatchedEntryIfNeeded();
+ addEntry(StreamDialogDefaultEntry.SHOW_CHANNEL_DETAILS);
+ return this;
+ }
+
+ /**
+ * Creates the {@link InfoItemDialog}.
+ * @return a new instance of {@link InfoItemDialog}
+ */
+ public InfoItemDialog create() {
+ if (addDefaultEntriesAutomatically) {
+ addDefaultEndEntries();
+ }
+ return new InfoItemDialog(this.activity, this.fragment, this.infoItem, this.entries);
+ }
+
+ public static void reportErrorDuringInitialization(final Throwable throwable,
+ final InfoItem item) {
+ ErrorUtil.showSnackbar(App.getApp().getBaseContext(), new ErrorInfo(
+ throwable,
+ UserAction.OPEN_INFO_ITEM_DIALOG,
+ "none",
+ item.getServiceId()));
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java
new file mode 100644
index 000000000..7e87318ee
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java
@@ -0,0 +1,142 @@
+package org.schabi.newpipe.info_list.dialog;
+
+import static org.schabi.newpipe.util.NavigationHelper.openChannelFragment;
+import static org.schabi.newpipe.util.SparseItemUtil.fetchItemInfoIfSparse;
+import static org.schabi.newpipe.util.SparseItemUtil.fetchUploaderUrlIfSparse;
+
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
+
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.database.stream.model.StreamEntity;
+import org.schabi.newpipe.local.dialog.PlaylistAppendDialog;
+import org.schabi.newpipe.local.dialog.PlaylistDialog;
+import org.schabi.newpipe.local.history.HistoryRecordManager;
+import org.schabi.newpipe.util.NavigationHelper;
+import org.schabi.newpipe.util.external_communication.KoreUtils;
+import org.schabi.newpipe.util.external_communication.ShareUtils;
+
+import java.util.Collections;
+
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+
+/**
+ *
+ * This enum provides entries that are accepted
+ * by the {@link InfoItemDialog.Builder}.
+ *
+ *
+ * These entries contain a String {@link #resource} which is displayed in the dialog and
+ * a default {@link #action} that is executed
+ * when the entry is selected (via onClick()).
+ *
+ * They action can be overridden by using the Builder's
+ * {@link InfoItemDialog.Builder#setAction(
+ * StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)}
+ * method.
+ *
+ */
+public enum StreamDialogDefaultEntry {
+ SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) ->
+ fetchUploaderUrlIfSparse(fragment.requireContext(), item.getServiceId(), item.getUrl(),
+ item.getUploaderUrl(), url -> openChannelFragment(fragment, item, url))
+ ),
+
+ /**
+ * Enqueues the stream automatically to the current PlayerType.
+ */
+ ENQUEUE(R.string.enqueue_stream, (fragment, item) ->
+ fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue ->
+ NavigationHelper.enqueueOnPlayer(fragment.getContext(), singlePlayQueue))
+ ),
+
+ /**
+ * Enqueues the stream automatically to the current PlayerType
+ * after the currently playing stream.
+ */
+ ENQUEUE_NEXT(R.string.enqueue_next_stream, (fragment, item) ->
+ fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue ->
+ NavigationHelper.enqueueNextOnPlayer(fragment.getContext(), singlePlayQueue))
+ ),
+
+ START_HERE_ON_BACKGROUND(R.string.start_here_on_background, (fragment, item) ->
+ fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue ->
+ NavigationHelper.playOnBackgroundPlayer(
+ fragment.getContext(), singlePlayQueue, true))),
+
+ START_HERE_ON_POPUP(R.string.start_here_on_popup, (fragment, item) ->
+ fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue ->
+ NavigationHelper.playOnPopupPlayer(fragment.getContext(), singlePlayQueue, true))),
+
+ SET_AS_PLAYLIST_THUMBNAIL(R.string.set_as_playlist_thumbnail, (fragment, item) -> {
+ throw new UnsupportedOperationException("This needs to be implemented manually "
+ + "by using InfoItemDialog.Builder.setAction()");
+ }),
+
+ DELETE(R.string.delete, (fragment, item) -> {
+ throw new UnsupportedOperationException("This needs to be implemented manually "
+ + "by using InfoItemDialog.Builder.setAction()");
+ }),
+
+ /**
+ * Opens a {@link PlaylistDialog} to either append the stream to a playlist
+ * or create a new playlist if there are no local playlists.
+ */
+ APPEND_PLAYLIST(R.string.add_to_playlist, (fragment, item) ->
+ PlaylistDialog.createCorrespondingDialog(
+ fragment.getContext(),
+ Collections.singletonList(new StreamEntity(item)),
+ dialog -> dialog.show(
+ fragment.getParentFragmentManager(),
+ "StreamDialogEntry@"
+ + (dialog instanceof PlaylistAppendDialog ? "append" : "create")
+ + "_playlist"
+ )
+ )
+ ),
+
+ PLAY_WITH_KODI(R.string.play_with_kodi_title, (fragment, item) -> {
+ final Uri videoUrl = Uri.parse(item.getUrl());
+ try {
+ NavigationHelper.playWithKore(fragment.requireContext(), videoUrl);
+ } catch (final Exception e) {
+ KoreUtils.showInstallKoreDialog(fragment.requireActivity());
+ }
+ }),
+
+ SHARE(R.string.share, (fragment, item) ->
+ ShareUtils.shareText(fragment.requireContext(), item.getName(), item.getUrl(),
+ item.getThumbnailUrl())),
+
+ OPEN_IN_BROWSER(R.string.open_in_browser, (fragment, item) ->
+ ShareUtils.openUrlInBrowser(fragment.requireContext(), item.getUrl())),
+
+
+ MARK_AS_WATCHED(R.string.mark_as_watched, (fragment, item) ->
+ new HistoryRecordManager(fragment.getContext())
+ .markAsWatched(item)
+ .onErrorComplete()
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe()
+ );
+
+
+ @StringRes
+ public final int resource;
+ @NonNull
+ public final StreamDialogEntry.StreamDialogEntryAction action;
+
+ StreamDialogDefaultEntry(@StringRes final int resource,
+ @NonNull final StreamDialogEntry.StreamDialogEntryAction action) {
+ this.resource = resource;
+ this.action = action;
+ }
+
+ @NonNull
+ public StreamDialogEntry toStreamDialogEntry() {
+ return new StreamDialogEntry(resource, action);
+ }
+
+}
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java
new file mode 100644
index 000000000..9d82e3b58
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java
@@ -0,0 +1,31 @@
+package org.schabi.newpipe.info_list.dialog;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
+import androidx.fragment.app.Fragment;
+
+import org.schabi.newpipe.extractor.stream.StreamInfoItem;
+
+public class StreamDialogEntry {
+
+ @StringRes
+ public final int resource;
+ @NonNull
+ public final StreamDialogEntryAction action;
+
+ public StreamDialogEntry(@StringRes final int resource,
+ @NonNull final StreamDialogEntryAction action) {
+ this.resource = resource;
+ this.action = action;
+ }
+
+ public String getString(@NonNull final Context context) {
+ return context.getString(resource);
+ }
+
+ public interface StreamDialogEntryAction {
+ void onClick(Fragment fragment, StreamInfoItem infoItem);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java
index 78acb752b..aa4f4c9f0 100644
--- a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java
@@ -1,6 +1,7 @@
package org.schabi.newpipe.info_list.holder;
import android.view.ViewGroup;
+import android.widget.ImageView;
import android.widget.TextView;
import org.schabi.newpipe.R;
@@ -11,10 +12,8 @@ import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.Localization;
-import de.hdodenhof.circleimageview.CircleImageView;
-
public class ChannelMiniInfoItemHolder extends InfoItemHolder {
- public final CircleImageView itemThumbnailView;
+ public final ImageView itemThumbnailView;
public final TextView itemTitleView;
private final TextView itemAdditionalDetailView;
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java
index cb47efa92..6e4773c09 100644
--- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java
@@ -7,6 +7,7 @@ import android.text.util.Linkify;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
+import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.TextView;
@@ -28,8 +29,6 @@ import org.schabi.newpipe.util.PicassoHelper;
import java.util.regex.Matcher;
-import de.hdodenhof.circleimageview.CircleImageView;
-
public class CommentsMiniInfoItemHolder extends InfoItemHolder {
private static final String TAG = "CommentsMiniIIHolder";
@@ -40,7 +39,7 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
private final int commentVerticalPadding;
private final RelativeLayout itemRoot;
- public final CircleImageView itemThumbnailView;
+ public final ImageView itemThumbnailView;
private final TextView itemContentView;
private final TextView itemLikesCountView;
private final TextView itemPublishedTime;
diff --git a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java
index 5d81c0069..05e2fdac0 100644
--- a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java
+++ b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java
@@ -228,6 +228,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter streamEntities) {
- super(streamEntities);
+ /**
+ * Create a new instance of {@link PlaylistAppendDialog}.
+ *
+ * @param streamEntities a list of {@link StreamEntity} to be added to playlists
+ * @return a new instance of {@link PlaylistAppendDialog}
+ */
+ public static PlaylistAppendDialog newInstance(final List streamEntities) {
+ final PlaylistAppendDialog dialog = new PlaylistAppendDialog();
+ dialog.setStreamEntities(streamEntities);
+ return dialog;
}
/*//////////////////////////////////////////////////////////////////////////
@@ -103,13 +111,14 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
// Helper
//////////////////////////////////////////////////////////////////////////*/
+ /** Display create playlist dialog. */
public void openCreatePlaylistDialog() {
if (getStreamEntities() == null || !isAdded()) {
return;
}
final PlaylistCreationDialog playlistCreationDialog =
- new PlaylistCreationDialog(getStreamEntities());
+ PlaylistCreationDialog.newInstance(getStreamEntities());
// Move the dismissListener to the new dialog.
playlistCreationDialog.setOnDismissListener(this.getOnDismissListener());
this.setOnDismissListener(null);
diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java
index 6664144cd..0c09f3f0d 100644
--- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java
@@ -21,8 +21,17 @@ import java.util.List;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
public final class PlaylistCreationDialog extends PlaylistDialog {
- public PlaylistCreationDialog(final List streamEntities) {
- super(streamEntities);
+
+ /**
+ * Create a new instance of {@link PlaylistCreationDialog}.
+ *
+ * @param streamEntities a list of {@link StreamEntity} to be added to playlists
+ * @return a new instance of {@link PlaylistCreationDialog}
+ */
+ public static PlaylistCreationDialog newInstance(final List streamEntities) {
+ final PlaylistCreationDialog dialog = new PlaylistCreationDialog();
+ dialog.setStreamEntities(streamEntities);
+ return dialog;
}
/*//////////////////////////////////////////////////////////////////////////
diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java
index c2d4474f8..f568ef81a 100644
--- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java
@@ -31,10 +31,6 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
private org.schabi.newpipe.util.SavedState savedState;
- public PlaylistDialog(final List streamEntities) {
- this.streamEntities = streamEntities;
- }
-
/*//////////////////////////////////////////////////////////////////////////
// LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@@ -97,7 +93,7 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
}
@Override
- public void onSaveInstanceState(final Bundle outState) {
+ public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState);
if (getActivity() != null) {
savedState = StateSaver.tryToSave(getActivity().isChangingConfigurations(),
@@ -120,6 +116,10 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
this.onDismissListener = onDismissListener;
}
+ protected void setStreamEntities(final List streamEntities) {
+ this.streamEntities = streamEntities;
+ }
+
/*//////////////////////////////////////////////////////////////////////////
// Dialog creation
//////////////////////////////////////////////////////////////////////////*/
@@ -143,8 +143,8 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
.observeOn(AndroidSchedulers.mainThread())
.subscribe(hasPlaylists ->
onExec.accept(hasPlaylists
- ? new PlaylistAppendDialog(streamEntities)
- : new PlaylistCreationDialog(streamEntities))
+ ? PlaylistAppendDialog.newInstance(streamEntities)
+ : PlaylistCreationDialog.newInstance(streamEntities))
);
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt
index e28f2d31a..7a8723ceb 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt
@@ -14,6 +14,7 @@ import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
import org.schabi.newpipe.database.stream.StreamWithState
import org.schabi.newpipe.database.stream.model.StreamEntity
+import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.local.subscription.FeedGroupIcon
@@ -57,6 +58,11 @@ class FeedDatabaseManager(context: Context) {
fun outdatedSubscriptions(outdatedThreshold: OffsetDateTime) = feedTable.getAllOutdated(outdatedThreshold)
+ fun outdatedSubscriptionsWithNotificationMode(
+ outdatedThreshold: OffsetDateTime,
+ @NotificationMode notificationMode: Int
+ ) = feedTable.getOutdatedWithNotificationMode(outdatedThreshold, notificationMode)
+
fun notLoadedCount(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable {
return when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> feedTable.notLoadedCount()
@@ -72,6 +78,10 @@ class FeedDatabaseManager(context: Context) {
fun markAsOutdated(subscriptionId: Long) = feedTable
.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null))
+ fun doesStreamExist(stream: StreamInfoItem): Boolean {
+ return streamTable.exists(stream.serviceId, stream.url)
+ }
+
fun upsertAll(
subscriptionId: Long,
items: List,
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt
index e6da0d545..e97629f31 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt
@@ -50,7 +50,6 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.xwray.groupie.GroupieAdapter
import com.xwray.groupie.Item
-import com.xwray.groupie.OnAsyncUpdateListener
import com.xwray.groupie.OnItemClickListener
import com.xwray.groupie.OnItemLongClickListener
import icepick.State
@@ -68,25 +67,21 @@ import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.stream.StreamInfoItem
-import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
import org.schabi.newpipe.fragments.BaseStateFragment
-import org.schabi.newpipe.info_list.InfoItemDialog
+import org.schabi.newpipe.info_list.dialog.InfoItemDialog
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling
import org.schabi.newpipe.ktx.slideUp
import org.schabi.newpipe.local.feed.item.StreamItem
import org.schabi.newpipe.local.feed.service.FeedLoadService
import org.schabi.newpipe.local.subscription.SubscriptionManager
-import org.schabi.newpipe.player.helper.PlayerHolder
import org.schabi.newpipe.util.DeviceUtils
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NavigationHelper
-import org.schabi.newpipe.util.StreamDialogEntry
import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
import java.time.OffsetDateTime
-import java.util.ArrayList
import java.util.function.Consumer
class FeedFragment : BaseStateFragment() {
@@ -143,7 +138,7 @@ class FeedFragment : BaseStateFragment() {
val factory = FeedViewModel.Factory(requireContext(), groupId)
viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java)
showPlayedItems = viewModel.getShowPlayedItemsFromPreferences()
- viewModel.stateLiveData.observe(viewLifecycleOwner, { it?.let(::handleResult) })
+ viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) }
groupAdapter = GroupieAdapter().apply {
setOnItemClickListener(listenerStreamItem)
@@ -356,53 +351,12 @@ class FeedFragment : BaseStateFragment() {
feedBinding.loadingProgressBar.max = progressState.maxProgress
}
- private fun showStreamDialog(item: StreamInfoItem) {
+ private fun showInfoItemDialog(item: StreamInfoItem) {
val context = context
val activity: Activity? = getActivity()
if (context == null || context.resources == null || activity == null) return
- val entries = ArrayList()
- if (PlayerHolder.getInstance().isPlayQueueReady) {
- entries.add(StreamDialogEntry.enqueue)
-
- if (PlayerHolder.getInstance().queueSize > 1) {
- entries.add(StreamDialogEntry.enqueue_next)
- }
- }
-
- if (item.streamType == StreamType.AUDIO_STREAM) {
- entries.addAll(
- listOf(
- StreamDialogEntry.start_here_on_background,
- StreamDialogEntry.append_playlist,
- StreamDialogEntry.share,
- StreamDialogEntry.open_in_browser
- )
- )
- } else {
- entries.addAll(
- listOf(
- StreamDialogEntry.start_here_on_background,
- StreamDialogEntry.start_here_on_popup,
- StreamDialogEntry.append_playlist,
- StreamDialogEntry.share,
- StreamDialogEntry.open_in_browser
- )
- )
- }
-
- // show "mark as watched" only when watch history is enabled
- if (StreamDialogEntry.shouldAddMarkAsWatched(item.streamType, context)) {
- entries.add(
- StreamDialogEntry.mark_as_watched
- )
- }
- entries.add(StreamDialogEntry.show_channel_details)
-
- StreamDialogEntry.setEnabledEntries(entries)
- InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context)) { _, which ->
- StreamDialogEntry.clickOn(which, this, item)
- }.show()
+ InfoItemDialog.Builder(activity, context, this, item).create().show()
}
private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener {
@@ -418,7 +372,7 @@ class FeedFragment : BaseStateFragment() {
override fun onItemLongClick(item: Item<*>, view: View): Boolean {
if (item is StreamItem && !isRefreshing) {
- showStreamDialog(item.streamWithState.stream.toStreamInfoItem())
+ showInfoItemDialog(item.streamWithState.stream.toStreamInfoItem())
return true
}
return false
@@ -438,14 +392,11 @@ class FeedFragment : BaseStateFragment() {
// This need to be saved in a variable as the update occurs async
val oldOldestSubscriptionUpdate = oldestSubscriptionUpdate
- groupAdapter.updateAsync(
- loadedState.items, false,
- OnAsyncUpdateListener {
- oldOldestSubscriptionUpdate?.run {
- highlightNewItemsAfter(oldOldestSubscriptionUpdate)
- }
+ groupAdapter.updateAsync(loadedState.items, false) {
+ oldOldestSubscriptionUpdate?.run {
+ highlightNewItemsAfter(oldOldestSubscriptionUpdate)
}
- )
+ }
listState?.run {
feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState)
@@ -497,8 +448,7 @@ class FeedFragment : BaseStateFragment() {
}.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
- {
- subscriptionEntity ->
+ { subscriptionEntity ->
handleFeedNotAvailable(
subscriptionEntity,
t.cause,
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt
index 2cbf9ad05..e21963c16 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt
@@ -56,7 +56,7 @@ class FeedViewModel(
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.map { (event, showPlayedItems, notLoadedCount, oldestUpdate) ->
- var streamItems = if (event is SuccessResultEvent || event is IdleEvent)
+ val streamItems = if (event is SuccessResultEvent || event is IdleEvent)
feedDatabaseManager
.getStreams(groupId, showPlayedItems)
.blockingGet(arrayListOf())
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt
new file mode 100644
index 000000000..3a08b3e4a
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt
@@ -0,0 +1,145 @@
+package org.schabi.newpipe.local.feed.notifications
+
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import androidx.core.content.ContextCompat
+import androidx.preference.PreferenceManager
+import org.schabi.newpipe.R
+import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import org.schabi.newpipe.local.feed.service.FeedUpdateInfo
+import org.schabi.newpipe.util.Localization
+import org.schabi.newpipe.util.NavigationHelper
+import org.schabi.newpipe.util.PicassoHelper
+
+/**
+ * Helper for everything related to show notifications about new streams to the user.
+ */
+class NotificationHelper(val context: Context) {
+
+ private val manager = context.getSystemService(
+ Context.NOTIFICATION_SERVICE
+ ) as NotificationManager
+
+ /**
+ * Show a notification about new streams from a single channel.
+ * Opening the notification will open the corresponding channel page.
+ */
+ fun displayNewStreamsNotification(data: FeedUpdateInfo) {
+ val newStreams: List = data.newStreams
+ val summary = context.resources.getQuantityString(
+ R.plurals.new_streams, newStreams.size, newStreams.size
+ )
+ val builder = NotificationCompat.Builder(
+ context,
+ context.getString(R.string.streams_notification_channel_id)
+ )
+ .setContentTitle(Localization.concatenateStrings(data.name, summary))
+ .setContentText(
+ data.listInfo.relatedItems.joinToString(
+ context.getString(R.string.enumeration_comma)
+ ) { x -> x.name }
+ )
+ .setNumber(newStreams.size)
+ .setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
+ .setSmallIcon(R.drawable.ic_newpipe_triangle_white)
+ .setColor(ContextCompat.getColor(context, R.color.ic_launcher_background))
+ .setColorized(true)
+ .setAutoCancel(true)
+ .setCategory(NotificationCompat.CATEGORY_SOCIAL)
+
+ // Build style
+ val style = NotificationCompat.InboxStyle()
+ newStreams.forEach { style.addLine(it.name) }
+ style.setSummaryText(summary)
+ style.setBigContentTitle(data.name)
+ builder.setStyle(style)
+
+ // open the channel page when clicking on the notification
+ builder.setContentIntent(
+ PendingIntent.getActivity(
+ context,
+ data.pseudoId,
+ NavigationHelper
+ .getChannelIntent(context, data.listInfo.serviceId, data.listInfo.url)
+ .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
+ PendingIntent.FLAG_IMMUTABLE
+ else
+ 0
+ )
+ )
+
+ PicassoHelper.loadNotificationIcon(data.avatarUrl) { bitmap ->
+ bitmap?.let { builder.setLargeIcon(it) } // set only if != null
+ manager.notify(data.pseudoId, builder.build())
+ }
+ }
+
+ companion object {
+ /**
+ * Check whether notifications are enabled on the device.
+ * Users can disable them via the system settings for a single app.
+ * If this is the case, the app cannot create any notifications
+ * and display them to the user.
+ *
+ * On Android 26 and above, notification channels are used by NewPipe.
+ * These can be configured by the user, too.
+ * The notification channel for new streams is also checked by this method.
+ *
+ * @param context Context
+ * @return true if notifications are allowed and can be displayed;
+ * false otherwise
+ */
+ fun areNotificationsEnabledOnDevice(context: Context): Boolean {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channelId = context.getString(R.string.streams_notification_channel_id)
+ val manager = context.getSystemService(
+ Context.NOTIFICATION_SERVICE
+ ) as NotificationManager
+ val enabled = manager.areNotificationsEnabled()
+ val channel = manager.getNotificationChannel(channelId)
+ val importance = channel?.importance
+ enabled && channel != null && importance != NotificationManager.IMPORTANCE_NONE
+ } else {
+ NotificationManagerCompat.from(context).areNotificationsEnabled()
+ }
+ }
+
+ /**
+ * Whether the user enabled the notifications for new streams in the app settings.
+ */
+ @JvmStatic
+ fun areNewStreamsNotificationsEnabled(context: Context): Boolean {
+ return (
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .getBoolean(context.getString(R.string.enable_streams_notifications), false) &&
+ areNotificationsEnabledOnDevice(context)
+ )
+ }
+
+ /**
+ * Open the system's notification settings for NewPipe on Android Oreo (API 26) and later.
+ * Open the system's app settings for NewPipe on previous Android versions.
+ */
+ fun openNewPipeSystemNotificationSettings(context: Context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
+ .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(intent)
+ } else {
+ val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+ intent.data = Uri.parse("package:" + context.packageName)
+ context.startActivity(intent)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt
new file mode 100644
index 000000000..6b9580802
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt
@@ -0,0 +1,170 @@
+package org.schabi.newpipe.local.feed.notifications
+
+import android.content.Context
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import androidx.work.Constraints
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ForegroundInfo
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.PeriodicWorkRequest
+import androidx.work.WorkManager
+import androidx.work.WorkerParameters
+import androidx.work.rxjava3.RxWorker
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.core.Single
+import org.schabi.newpipe.App
+import org.schabi.newpipe.R
+import org.schabi.newpipe.error.ErrorInfo
+import org.schabi.newpipe.error.ErrorUtil
+import org.schabi.newpipe.error.UserAction
+import org.schabi.newpipe.local.feed.service.FeedLoadManager
+import org.schabi.newpipe.local.feed.service.FeedLoadService
+import java.util.concurrent.TimeUnit
+
+/*
+ * Worker which checks for new streams of subscribed channels
+ * in intervals which can be set by the user in the settings.
+ */
+class NotificationWorker(
+ appContext: Context,
+ workerParams: WorkerParameters,
+) : RxWorker(appContext, workerParams) {
+
+ private val notificationHelper by lazy {
+ NotificationHelper(appContext)
+ }
+ private val feedLoadManager = FeedLoadManager(appContext)
+
+ override fun createWork(): Single = if (areNotificationsEnabled(applicationContext)) {
+ feedLoadManager.startLoading(
+ ignoreOutdatedThreshold = true,
+ groupId = FeedLoadManager.GROUP_NOTIFICATION_ENABLED
+ )
+ .doOnSubscribe { showLoadingFeedForegroundNotification() }
+ .map { feed ->
+ // filter out feedUpdateInfo items (i.e. channels) with nothing new
+ feed.mapNotNull {
+ it.value?.takeIf { feedUpdateInfo ->
+ feedUpdateInfo.newStreams.isNotEmpty()
+ }
+ }
+ }
+ .observeOn(AndroidSchedulers.mainThread()) // Picasso requires calls from main thread
+ .map { feedUpdateInfoList ->
+ // display notifications for each feedUpdateInfo (i.e. channel)
+ feedUpdateInfoList.forEach { feedUpdateInfo ->
+ notificationHelper.displayNewStreamsNotification(feedUpdateInfo)
+ }
+ return@map Result.success()
+ }
+ .doOnError { throwable ->
+ Log.e(TAG, "Error while displaying streams notifications", throwable)
+ ErrorUtil.createNotification(
+ applicationContext,
+ ErrorInfo(throwable, UserAction.NEW_STREAMS_NOTIFICATIONS, "main worker")
+ )
+ }
+ .onErrorReturnItem(Result.failure())
+ } else {
+ // the user can disable streams notifications in the device's app settings
+ Single.just(Result.success())
+ }
+
+ private fun showLoadingFeedForegroundNotification() {
+ val notification = NotificationCompat.Builder(
+ applicationContext,
+ applicationContext.getString(R.string.notification_channel_id)
+ ).setOngoing(true)
+ .setProgress(-1, -1, true)
+ .setSmallIcon(R.drawable.ic_newpipe_triangle_white)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setContentTitle(applicationContext.getString(R.string.feed_notification_loading))
+ .build()
+ setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification))
+ }
+
+ companion object {
+
+ private val TAG = NotificationWorker::class.java.simpleName
+ private const val WORK_TAG = App.PACKAGE_NAME + "_streams_notifications"
+
+ private fun areNotificationsEnabled(context: Context) =
+ NotificationHelper.areNewStreamsNotificationsEnabled(context) &&
+ NotificationHelper.areNotificationsEnabledOnDevice(context)
+
+ /**
+ * Schedules a task for the [NotificationWorker]
+ * if the (device and in-app) notifications are enabled,
+ * otherwise [cancel]s all scheduled tasks.
+ */
+ @JvmStatic
+ fun initialize(context: Context) {
+ if (areNotificationsEnabled(context)) {
+ schedule(context)
+ } else {
+ cancel(context)
+ }
+ }
+
+ /**
+ * @param context the context to use
+ * @param options configuration options for the scheduler
+ * @param force Force the scheduler to use the new options
+ * by replacing the previously used worker.
+ */
+ fun schedule(context: Context, options: ScheduleOptions, force: Boolean = false) {
+ val constraints = Constraints.Builder()
+ .setRequiredNetworkType(
+ if (options.isRequireNonMeteredNetwork) {
+ NetworkType.UNMETERED
+ } else {
+ NetworkType.CONNECTED
+ }
+ ).build()
+
+ val request = PeriodicWorkRequest.Builder(
+ NotificationWorker::class.java,
+ options.interval,
+ TimeUnit.MILLISECONDS
+ ).setConstraints(constraints)
+ .addTag(WORK_TAG)
+ .build()
+
+ WorkManager.getInstance(context)
+ .enqueueUniquePeriodicWork(
+ WORK_TAG,
+ if (force) {
+ ExistingPeriodicWorkPolicy.REPLACE
+ } else {
+ ExistingPeriodicWorkPolicy.KEEP
+ },
+ request
+ )
+ }
+
+ @JvmStatic
+ fun schedule(context: Context) = schedule(context, ScheduleOptions.from(context))
+
+ /**
+ * Check for new streams immediately
+ */
+ @JvmStatic
+ fun runNow(context: Context) {
+ val request = OneTimeWorkRequestBuilder()
+ .addTag(WORK_TAG)
+ .build()
+ WorkManager.getInstance(context).enqueue(request)
+ }
+
+ /**
+ * Cancels all current work related to the [NotificationWorker].
+ */
+ @JvmStatic
+ fun cancel(context: Context) {
+ WorkManager.getInstance(context).cancelAllWorkByTag(WORK_TAG)
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/ScheduleOptions.kt b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/ScheduleOptions.kt
new file mode 100644
index 000000000..37e8fc39e
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/ScheduleOptions.kt
@@ -0,0 +1,37 @@
+package org.schabi.newpipe.local.feed.notifications
+
+import android.content.Context
+import androidx.preference.PreferenceManager
+import org.schabi.newpipe.R
+import java.util.concurrent.TimeUnit
+
+/**
+ * Information for the Scheduler which checks for new streams.
+ * See [NotificationWorker]
+ */
+data class ScheduleOptions(
+ val interval: Long,
+ val isRequireNonMeteredNetwork: Boolean
+) {
+
+ companion object {
+
+ fun from(context: Context): ScheduleOptions {
+ val preferences = PreferenceManager.getDefaultSharedPreferences(context)
+ return ScheduleOptions(
+ interval = TimeUnit.SECONDS.toMillis(
+ preferences.getString(
+ context.getString(R.string.streams_notifications_interval_key),
+ null
+ )?.toLongOrNull() ?: context.getString(
+ R.string.streams_notifications_interval_default
+ ).toLong()
+ ),
+ isRequireNonMeteredNetwork = preferences.getString(
+ context.getString(R.string.streams_notifications_network_key),
+ context.getString(R.string.streams_notifications_network_default)
+ ) == context.getString(R.string.streams_notifications_network_wifi)
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt
new file mode 100644
index 000000000..fec50a579
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt
@@ -0,0 +1,270 @@
+package org.schabi.newpipe.local.feed.service
+
+import android.content.Context
+import androidx.preference.PreferenceManager
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.core.Completable
+import io.reactivex.rxjava3.core.Flowable
+import io.reactivex.rxjava3.core.Notification
+import io.reactivex.rxjava3.core.Single
+import io.reactivex.rxjava3.functions.Consumer
+import io.reactivex.rxjava3.processors.PublishProcessor
+import io.reactivex.rxjava3.schedulers.Schedulers
+import org.schabi.newpipe.R
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.database.subscription.NotificationMode
+import org.schabi.newpipe.extractor.ListInfo
+import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import org.schabi.newpipe.local.feed.FeedDatabaseManager
+import org.schabi.newpipe.local.subscription.SubscriptionManager
+import org.schabi.newpipe.util.ExtractorHelper
+import java.time.OffsetDateTime
+import java.time.ZoneOffset
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+
+class FeedLoadManager(private val context: Context) {
+
+ private val subscriptionManager = SubscriptionManager(context)
+ private val feedDatabaseManager = FeedDatabaseManager(context)
+
+ private val notificationUpdater = PublishProcessor.create()
+ private val currentProgress = AtomicInteger(-1)
+ private val maxProgress = AtomicInteger(-1)
+ private val cancelSignal = AtomicBoolean()
+ private val feedResultsHolder = FeedResultsHolder()
+
+ val notification: Flowable = notificationUpdater.map { description ->
+ FeedLoadState(description, maxProgress.get(), currentProgress.get())
+ }
+
+ /**
+ * Start checking for new streams of a subscription group.
+ * @param groupId The ID of the subscription group to load. When using
+ * [FeedGroupEntity.GROUP_ALL_ID], all subscriptions are loaded. When using
+ * [GROUP_NOTIFICATION_ENABLED], only subscriptions with enabled notifications for new streams
+ * are loaded. Using an id of a group created by the user results in that specific group to be
+ * loaded.
+ * @param ignoreOutdatedThreshold When `false`, only subscriptions which have not been updated
+ * within the `feed_update_threshold` are checked for updates. This threshold can be set by
+ * the user in the app settings. When `true`, all subscriptions are checked for new streams.
+ */
+ fun startLoading(
+ groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
+ ignoreOutdatedThreshold: Boolean = false,
+ ): Single>> {
+ val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
+ val useFeedExtractor = defaultSharedPreferences.getBoolean(
+ context.getString(R.string.feed_use_dedicated_fetch_method_key),
+ false
+ )
+
+ val outdatedThreshold = if (ignoreOutdatedThreshold) {
+ OffsetDateTime.now(ZoneOffset.UTC)
+ } else {
+ val thresholdOutdatedSeconds = (
+ defaultSharedPreferences.getString(
+ context.getString(R.string.feed_update_threshold_key),
+ context.getString(R.string.feed_update_threshold_default_value)
+ ) ?: context.getString(R.string.feed_update_threshold_default_value)
+ ).toInt()
+ OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong())
+ }
+
+ /**
+ * subscriptions which have not been updated within the feed updated threshold
+ */
+ val outdatedSubscriptions = when (groupId) {
+ FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
+ GROUP_NOTIFICATION_ENABLED -> feedDatabaseManager.outdatedSubscriptionsWithNotificationMode(
+ outdatedThreshold, NotificationMode.ENABLED
+ )
+ else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
+ }
+
+ return outdatedSubscriptions
+ .take(1)
+ .doOnNext {
+ currentProgress.set(0)
+ maxProgress.set(it.size)
+ }
+ .filter { it.isNotEmpty() }
+ .observeOn(AndroidSchedulers.mainThread())
+ .doOnNext {
+ notificationUpdater.onNext("")
+ broadcastProgress()
+ }
+ .observeOn(Schedulers.io())
+ .flatMap { Flowable.fromIterable(it) }
+ .takeWhile { !cancelSignal.get() }
+ .parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2)
+ .runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
+ .filter { !cancelSignal.get() }
+ .map { subscriptionEntity ->
+ var error: Throwable? = null
+ try {
+ // check for and load new streams
+ // either by using the dedicated feed method or by getting the channel info
+ val listInfo = if (useFeedExtractor) {
+ ExtractorHelper
+ .getFeedInfoFallbackToChannelInfo(
+ subscriptionEntity.serviceId,
+ subscriptionEntity.url
+ )
+ .onErrorReturn {
+ error = it // store error, otherwise wrapped into RuntimeException
+ throw it
+ }
+ .blockingGet()
+ } else {
+ ExtractorHelper
+ .getChannelInfo(
+ subscriptionEntity.serviceId,
+ subscriptionEntity.url,
+ true
+ )
+ .onErrorReturn {
+ error = it // store error, otherwise wrapped into RuntimeException
+ throw it
+ }
+ .blockingGet()
+ } as ListInfo
+
+ return@map Notification.createOnNext(
+ FeedUpdateInfo(
+ subscriptionEntity,
+ listInfo
+ )
+ )
+ } catch (e: Throwable) {
+ if (error == null) {
+ // do this to prevent blockingGet() from wrapping into RuntimeException
+ error = e
+ }
+
+ val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
+ val wrapper =
+ FeedLoadService.RequestException(subscriptionEntity.uid, request, error!!)
+ return@map Notification.createOnError(wrapper)
+ }
+ }
+ .sequential()
+ .observeOn(AndroidSchedulers.mainThread())
+ .doOnNext(NotificationConsumer())
+ .observeOn(Schedulers.io())
+ .buffer(BUFFER_COUNT_BEFORE_INSERT)
+ .doOnNext(DatabaseConsumer())
+ .subscribeOn(Schedulers.io())
+ .toList()
+ .flatMap { x -> postProcessFeed().toSingleDefault(x.flatten()) }
+ }
+
+ fun cancel() {
+ cancelSignal.set(true)
+ }
+
+ private fun broadcastProgress() {
+ FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(currentProgress.get(), maxProgress.get()))
+ }
+
+ /**
+ * Keep the feed and the stream tables small
+ * to reduce loading times when trying to display the feed.
+ *
+ * Remove streams from the feed which are older than [FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE].
+ * Remove streams from the database which are not linked / used by any table.
+ */
+ private fun postProcessFeed() = Completable.fromRunnable {
+ FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message))
+ feedDatabaseManager.removeOrphansOrOlderStreams()
+
+ FeedEventManager.postEvent(FeedEventManager.Event.SuccessResultEvent(feedResultsHolder.itemsErrors))
+ }.doOnSubscribe {
+ currentProgress.set(-1)
+ maxProgress.set(-1)
+
+ notificationUpdater.onNext(context.getString(R.string.feed_processing_message))
+ FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message))
+ }.subscribeOn(Schedulers.io())
+
+ private inner class NotificationConsumer : Consumer> {
+ override fun accept(item: Notification) {
+ currentProgress.incrementAndGet()
+ notificationUpdater.onNext(item.value?.name.orEmpty())
+
+ broadcastProgress()
+ }
+ }
+
+ private inner class DatabaseConsumer : Consumer>> {
+
+ override fun accept(list: List>) {
+ feedDatabaseManager.database().runInTransaction {
+ for (notification in list) {
+ when {
+ notification.isOnNext -> {
+ val subscriptionId = notification.value!!.uid
+ val info = notification.value!!.listInfo
+
+ notification.value!!.newStreams = filterNewStreams(
+ notification.value!!.listInfo.relatedItems
+ )
+
+ feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
+ subscriptionManager.updateFromInfo(subscriptionId, info)
+
+ if (info.errors.isNotEmpty()) {
+ feedResultsHolder.addErrors(
+ FeedLoadService.RequestException.wrapList(
+ subscriptionId,
+ info
+ )
+ )
+ feedDatabaseManager.markAsOutdated(subscriptionId)
+ }
+ }
+ notification.isOnError -> {
+ val error = notification.error
+ feedResultsHolder.addError(error!!)
+
+ if (error is FeedLoadService.RequestException) {
+ feedDatabaseManager.markAsOutdated(error.subscriptionId)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun filterNewStreams(list: List): List {
+ return list.filter {
+ !feedDatabaseManager.doesStreamExist(it) &&
+ it.uploadDate != null &&
+ // Streams older than this date are automatically removed from the feed.
+ // Therefore, streams which are not in the database,
+ // but older than this date, are considered old.
+ it.uploadDate!!.offsetDateTime().isAfter(
+ FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE
+ )
+ }
+ }
+ }
+
+ companion object {
+
+ /**
+ * Constant used to check for updates of subscriptions with [NotificationMode.ENABLED].
+ */
+ const val GROUP_NOTIFICATION_ENABLED = -2L
+
+ /**
+ * How many extractions will be running in parallel.
+ */
+ private const val PARALLEL_EXTRACTIONS = 6
+
+ /**
+ * Number of items to buffer to mass-insert in the database.
+ */
+ private const val BUFFER_COUNT_BEFORE_INSERT = 20
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt
index 5bc097fe5..f2ea40416 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt
@@ -31,41 +31,24 @@ import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
-import androidx.preference.PreferenceManager
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
-import io.reactivex.rxjava3.core.Notification
-import io.reactivex.rxjava3.core.Single
-import io.reactivex.rxjava3.disposables.CompositeDisposable
-import io.reactivex.rxjava3.functions.Consumer
+import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.functions.Function
-import io.reactivex.rxjava3.processors.PublishProcessor
-import io.reactivex.rxjava3.schedulers.Schedulers
-import org.reactivestreams.Subscriber
-import org.reactivestreams.Subscription
import org.schabi.newpipe.App
import org.schabi.newpipe.MainActivity.DEBUG
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.extractor.ListInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
-import org.schabi.newpipe.local.feed.FeedDatabaseManager
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
-import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ProgressEvent
-import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.SuccessResultEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
-import org.schabi.newpipe.local.subscription.SubscriptionManager
-import org.schabi.newpipe.util.ExtractorHelper
-import java.time.OffsetDateTime
-import java.time.ZoneOffset
import java.util.concurrent.TimeUnit
-import java.util.concurrent.atomic.AtomicBoolean
-import java.util.concurrent.atomic.AtomicInteger
class FeedLoadService : Service() {
companion object {
private val TAG = FeedLoadService::class.java.simpleName
- private const val NOTIFICATION_ID = 7293450
+ const val NOTIFICATION_ID = 7293450
private const val ACTION_CANCEL = App.PACKAGE_NAME + ".local.feed.service.FeedLoadService.CANCEL"
/**
@@ -73,27 +56,13 @@ class FeedLoadService : Service() {
*/
private const val NOTIFICATION_SAMPLING_PERIOD = 1500
- /**
- * How many extractions will be running in parallel.
- */
- private const val PARALLEL_EXTRACTIONS = 6
-
- /**
- * Number of items to buffer to mass-insert in the database.
- */
- private const val BUFFER_COUNT_BEFORE_INSERT = 20
-
const val EXTRA_GROUP_ID: String = "FeedLoadService.EXTRA_GROUP_ID"
}
- private var loadingSubscription: Subscription? = null
- private lateinit var subscriptionManager: SubscriptionManager
+ private var loadingDisposable: Disposable? = null
+ private var notificationDisposable: Disposable? = null
- private lateinit var feedDatabaseManager: FeedDatabaseManager
- private lateinit var feedResultsHolder: ResultsHolder
-
- private var disposables = CompositeDisposable()
- private var notificationUpdater = PublishProcessor.create()
+ private lateinit var feedLoadManager: FeedLoadManager
// /////////////////////////////////////////////////////////////////////////
// Lifecycle
@@ -101,8 +70,7 @@ class FeedLoadService : Service() {
override fun onCreate() {
super.onCreate()
- subscriptionManager = SubscriptionManager(this)
- feedDatabaseManager = FeedDatabaseManager(this)
+ feedLoadManager = FeedLoadManager(this)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -114,40 +82,45 @@ class FeedLoadService : Service() {
)
}
- if (intent == null || loadingSubscription != null) {
+ if (intent == null || loadingDisposable != null) {
return START_NOT_STICKY
}
setupNotification()
setupBroadcastReceiver()
- val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
val groupId = intent.getLongExtra(EXTRA_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID)
- val useFeedExtractor = defaultSharedPreferences
- .getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false)
-
- val thresholdOutdatedSecondsString = defaultSharedPreferences
- .getString(getString(R.string.feed_update_threshold_key), getString(R.string.feed_update_threshold_default_value))
- val thresholdOutdatedSeconds = thresholdOutdatedSecondsString!!.toInt()
-
- startLoading(groupId, useFeedExtractor, thresholdOutdatedSeconds)
-
+ loadingDisposable = feedLoadManager.startLoading(groupId)
+ .observeOn(AndroidSchedulers.mainThread())
+ .doOnSubscribe {
+ startForeground(NOTIFICATION_ID, notificationBuilder.build())
+ }
+ .subscribe { _, error ->
+ // There seems to be a bug in the kotlin plugin as it tells you when
+ // building that this can't be null:
+ // "Condition 'error != null' is always 'true'"
+ // However it can indeed be null
+ // The suppression may be removed in further versions
+ @Suppress("SENSELESS_COMPARISON")
+ if (error != null) {
+ Log.e(TAG, "Error while storing result", error)
+ handleError(error)
+ return@subscribe
+ }
+ stopService()
+ }
return START_NOT_STICKY
}
private fun disposeAll() {
unregisterReceiver(broadcastReceiver)
-
- loadingSubscription?.cancel()
- loadingSubscription = null
-
- disposables.dispose()
+ loadingDisposable?.dispose()
+ notificationDisposable?.dispose()
}
private fun stopService() {
disposeAll()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
- notificationManager.cancel(NOTIFICATION_ID)
stopSelf()
}
@@ -171,182 +144,6 @@ class FeedLoadService : Service() {
}
}
- private fun startLoading(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, useFeedExtractor: Boolean, thresholdOutdatedSeconds: Int) {
- feedResultsHolder = ResultsHolder()
-
- val outdatedThreshold = OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong())
-
- val subscriptions = when (groupId) {
- FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
- else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
- }
-
- subscriptions
- .take(1)
- .doOnNext {
- currentProgress.set(0)
- maxProgress.set(it.size)
- }
- .filter { it.isNotEmpty() }
- .observeOn(AndroidSchedulers.mainThread())
- .doOnNext {
- startForeground(NOTIFICATION_ID, notificationBuilder.build())
- updateNotificationProgress(null)
- broadcastProgress()
- }
- .observeOn(Schedulers.io())
- .flatMap { Flowable.fromIterable(it) }
- .takeWhile { !cancelSignal.get() }
- .parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2)
- .runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
- .filter { !cancelSignal.get() }
- .map { subscriptionEntity ->
- var error: Throwable? = null
- try {
- val listInfo = if (useFeedExtractor) {
- ExtractorHelper
- .getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url)
- .onErrorReturn {
- error = it // store error, otherwise wrapped into RuntimeException
- throw it
- }
- .blockingGet()
- } else {
- ExtractorHelper
- .getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true)
- .onErrorReturn {
- error = it // store error, otherwise wrapped into RuntimeException
- throw it
- }
- .blockingGet()
- } as ListInfo
-
- return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo))
- } catch (e: Throwable) {
- if (error == null) {
- // do this to prevent blockingGet() from wrapping into RuntimeException
- error = e
- }
-
- val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
- val wrapper = RequestException(subscriptionEntity.uid, request, error!!)
- return@map Notification.createOnError>>(wrapper)
- }
- }
- .sequential()
- .observeOn(AndroidSchedulers.mainThread())
- .doOnNext(notificationsConsumer)
- .observeOn(Schedulers.io())
- .buffer(BUFFER_COUNT_BEFORE_INSERT)
- .doOnNext(databaseConsumer)
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(resultSubscriber)
- }
-
- private fun broadcastProgress() {
- postEvent(ProgressEvent(currentProgress.get(), maxProgress.get()))
- }
-
- private val resultSubscriber
- get() = object : Subscriber>>>> {
-
- override fun onSubscribe(s: Subscription) {
- loadingSubscription = s
- s.request(java.lang.Long.MAX_VALUE)
- }
-
- override fun onNext(notification: List>>>) {
- if (DEBUG) Log.v(TAG, "onNext() → $notification")
- }
-
- override fun onError(error: Throwable) {
- handleError(error)
- }
-
- override fun onComplete() {
- if (maxProgress.get() == 0) {
- postEvent(FeedEventManager.Event.IdleEvent)
- stopService()
-
- return
- }
-
- currentProgress.set(-1)
- maxProgress.set(-1)
-
- notificationUpdater.onNext(getString(R.string.feed_processing_message))
- postEvent(ProgressEvent(R.string.feed_processing_message))
-
- disposables.add(
- Single
- .fromCallable {
- feedResultsHolder.ready()
-
- postEvent(ProgressEvent(R.string.feed_processing_message))
- feedDatabaseManager.removeOrphansOrOlderStreams()
-
- postEvent(SuccessResultEvent(feedResultsHolder.itemsErrors))
- true
- }
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe { _, throwable ->
- // There seems to be a bug in the kotlin plugin as it tells you when
- // building that this can't be null:
- // "Condition 'throwable != null' is always 'true'"
- // However it can indeed be null
- // The suppression may be removed in further versions
- @Suppress("SENSELESS_COMPARISON")
- if (throwable != null) {
- Log.e(TAG, "Error while storing result", throwable)
- handleError(throwable)
- return@subscribe
- }
- stopService()
- }
- )
- }
- }
-
- private val databaseConsumer: Consumer>>>>
- get() = Consumer {
- feedDatabaseManager.database().runInTransaction {
- for (notification in it) {
-
- if (notification.isOnNext) {
- val subscriptionId = notification.value!!.first
- val info = notification.value!!.second
-
- feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
- subscriptionManager.updateFromInfo(subscriptionId, info)
-
- if (info.errors.isNotEmpty()) {
- feedResultsHolder.addErrors(RequestException.wrapList(subscriptionId, info))
- feedDatabaseManager.markAsOutdated(subscriptionId)
- }
- } else if (notification.isOnError) {
- val error = notification.error!!
- feedResultsHolder.addError(error)
-
- if (error is RequestException) {
- feedDatabaseManager.markAsOutdated(error.subscriptionId)
- }
- }
- }
- }
- }
-
- private val notificationsConsumer: Consumer>>>
- get() = Consumer { onItemCompleted(it.value?.second?.name) }
-
- private fun onItemCompleted(updateDescription: String?) {
- currentProgress.incrementAndGet()
- notificationUpdater.onNext(updateDescription ?: "")
-
- broadcastProgress()
- }
-
// /////////////////////////////////////////////////////////////////////////
// Notification
// /////////////////////////////////////////////////////////////////////////
@@ -354,13 +151,12 @@ class FeedLoadService : Service() {
private lateinit var notificationManager: NotificationManagerCompat
private lateinit var notificationBuilder: NotificationCompat.Builder
- private var currentProgress = AtomicInteger(-1)
- private var maxProgress = AtomicInteger(-1)
-
private fun createNotification(): NotificationCompat.Builder {
val cancelActionIntent = PendingIntent.getBroadcast(
this,
- NOTIFICATION_ID, Intent(ACTION_CANCEL), 0
+ NOTIFICATION_ID,
+ Intent(ACTION_CANCEL),
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
)
return NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
@@ -376,33 +172,36 @@ class FeedLoadService : Service() {
notificationManager = NotificationManagerCompat.from(this)
notificationBuilder = createNotification()
- val throttleAfterFirstEmission = Function { flow: Flowable ->
+ val throttleAfterFirstEmission = Function { flow: Flowable ->
flow.take(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS))
}
- disposables.add(
- notificationUpdater
- .publish(throttleAfterFirstEmission)
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(this::updateNotificationProgress)
- )
+ notificationDisposable = feedLoadManager.notification
+ .publish(throttleAfterFirstEmission)
+ .observeOn(AndroidSchedulers.mainThread())
+ .doOnTerminate { notificationManager.cancel(NOTIFICATION_ID) }
+ .subscribe(this::updateNotificationProgress)
}
- private fun updateNotificationProgress(updateDescription: String?) {
- notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1)
+ private fun updateNotificationProgress(state: FeedLoadState) {
+ notificationBuilder.setProgress(state.maxProgress, state.currentProgress, state.maxProgress == -1)
- if (maxProgress.get() == -1) {
+ if (state.maxProgress == -1) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null)
- if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
- notificationBuilder.setContentText(updateDescription)
+ if (state.updateDescription.isNotEmpty()) notificationBuilder.setContentText(state.updateDescription)
+ notificationBuilder.setContentText(state.updateDescription)
} else {
- val progressText = this.currentProgress.toString() + "/" + maxProgress
+ val progressText = state.currentProgress.toString() + "/" + state.maxProgress
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText("$updateDescription ($progressText)")
+ if (state.updateDescription.isNotEmpty()) {
+ notificationBuilder.setContentText("${state.updateDescription} ($progressText)")
+ }
} else {
notificationBuilder.setContentInfo(progressText)
- if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
+ if (state.updateDescription.isNotEmpty()) {
+ notificationBuilder.setContentText(state.updateDescription)
+ }
}
}
@@ -414,13 +213,12 @@ class FeedLoadService : Service() {
// /////////////////////////////////////////////////////////////////////////
private lateinit var broadcastReceiver: BroadcastReceiver
- private val cancelSignal = AtomicBoolean()
private fun setupBroadcastReceiver() {
broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == ACTION_CANCEL) {
- cancelSignal.set(true)
+ feedLoadManager.cancel()
}
}
}
@@ -435,29 +233,4 @@ class FeedLoadService : Service() {
postEvent(ErrorResultEvent(error))
stopService()
}
-
- // /////////////////////////////////////////////////////////////////////////
- // Results Holder
- // /////////////////////////////////////////////////////////////////////////
-
- class ResultsHolder {
- /**
- * List of errors that may have happen during loading.
- */
- internal lateinit var itemsErrors: List
-
- private val itemsErrorsHolder: MutableList = ArrayList()
-
- fun addError(error: Throwable) {
- itemsErrorsHolder.add(error)
- }
-
- fun addErrors(errors: List) {
- itemsErrorsHolder.addAll(errors)
- }
-
- fun ready() {
- itemsErrors = itemsErrorsHolder.toList()
- }
- }
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadState.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadState.kt
new file mode 100644
index 000000000..703f593ad
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadState.kt
@@ -0,0 +1,7 @@
+package org.schabi.newpipe.local.feed.service
+
+data class FeedLoadState(
+ val updateDescription: String,
+ val maxProgress: Int,
+ val currentProgress: Int,
+)
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedResultsHolder.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedResultsHolder.kt
new file mode 100644
index 000000000..729f2c009
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedResultsHolder.kt
@@ -0,0 +1,19 @@
+package org.schabi.newpipe.local.feed.service
+
+class FeedResultsHolder {
+ /**
+ * List of errors that may have happen during loading.
+ */
+ val itemsErrors: List
+ get() = itemsErrorsHolder
+
+ private val itemsErrorsHolder: MutableList = ArrayList()
+
+ fun addError(error: Throwable) {
+ itemsErrorsHolder.add(error)
+ }
+
+ fun addErrors(errors: List) {
+ itemsErrorsHolder.addAll(errors)
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt
new file mode 100644
index 000000000..5f72a6b84
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt
@@ -0,0 +1,34 @@
+package org.schabi.newpipe.local.feed.service
+
+import org.schabi.newpipe.database.subscription.NotificationMode
+import org.schabi.newpipe.database.subscription.SubscriptionEntity
+import org.schabi.newpipe.extractor.ListInfo
+import org.schabi.newpipe.extractor.stream.StreamInfoItem
+
+data class FeedUpdateInfo(
+ val uid: Long,
+ @NotificationMode
+ val notificationMode: Int,
+ val name: String,
+ val avatarUrl: String,
+ val listInfo: ListInfo,
+) {
+ constructor(
+ subscription: SubscriptionEntity,
+ listInfo: ListInfo,
+ ) : this(
+ uid = subscription.uid,
+ notificationMode = subscription.notificationMode,
+ name = subscription.name,
+ avatarUrl = subscription.avatarUrl,
+ listInfo = listInfo,
+ )
+
+ /**
+ * Integer id, can be used as notification id, etc.
+ */
+ val pseudoId: Int
+ get() = listInfo.url.hashCode()
+
+ lateinit var newStreams: List
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java
index e7ccd07d2..709a16b68 100644
--- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java
+++ b/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java
@@ -84,7 +84,7 @@ public abstract class HistoryEntryAdapter
}
@Override
- public void onViewRecycled(final VH holder) {
+ public void onViewRecycled(@NonNull final VH holder) {
super.onViewRecycled(holder);
holder.itemView.setOnClickListener(null);
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
index 73682d5d5..01df34292 100644
--- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
@@ -1,6 +1,5 @@
package org.schabi.newpipe.local.history;
-import android.app.Activity;
import android.content.Context;
import android.os.Bundle;
import android.os.Parcelable;
@@ -29,20 +28,16 @@ import org.schabi.newpipe.databinding.StatisticPlaylistControlBinding;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
-import org.schabi.newpipe.extractor.stream.StreamType;
-import org.schabi.newpipe.info_list.InfoItemDialog;
+import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.local.BaseLocalListFragment;
-import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.settings.HistorySettingsFragment;
-import org.schabi.newpipe.util.external_communication.KoreUtils;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
-import org.schabi.newpipe.util.StreamDialogEntry;
+import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
@@ -154,7 +149,7 @@ public class StatisticsPlaylistFragment
@Override
public void held(final LocalItem selectedItem) {
if (selectedItem instanceof StreamStatisticsEntry) {
- showStreamDialog((StreamStatisticsEntry) selectedItem);
+ showInfoItemDialog((StreamStatisticsEntry) selectedItem);
}
}
});
@@ -328,66 +323,30 @@ public class StatisticsPlaylistFragment
return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0));
}
- private void showStreamDialog(final StreamStatisticsEntry item) {
+ private void showInfoItemDialog(final StreamStatisticsEntry item) {
final Context context = getContext();
- final Activity activity = getActivity();
- if (context == null || context.getResources() == null || activity == null) {
- return;
- }
final StreamInfoItem infoItem = item.toStreamInfoItem();
- final ArrayList entries = new ArrayList<>();
+ try {
+ final InfoItemDialog.Builder dialogBuilder =
+ new InfoItemDialog.Builder(getActivity(), context, this, infoItem);
- if (PlayerHolder.getInstance().isPlayQueueReady()) {
- entries.add(StreamDialogEntry.enqueue);
-
- if (PlayerHolder.getInstance().getQueueSize() > 1) {
- entries.add(StreamDialogEntry.enqueue_next);
- }
+ // set entries in the middle; the others are added automatically
+ dialogBuilder
+ .addEntry(StreamDialogDefaultEntry.DELETE)
+ .setAction(
+ StreamDialogDefaultEntry.DELETE,
+ (f, i) -> deleteEntry(
+ Math.max(itemListAdapter.getItemsList().indexOf(item), 0)))
+ .setAction(
+ StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND,
+ (f, i) -> NavigationHelper.playOnBackgroundPlayer(
+ context, getPlayQueueStartingAt(item), true))
+ .create()
+ .show();
+ } catch (final IllegalArgumentException e) {
+ InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem);
}
-
- if (infoItem.getStreamType() == StreamType.AUDIO_STREAM) {
- entries.addAll(Arrays.asList(
- StreamDialogEntry.start_here_on_background,
- StreamDialogEntry.delete,
- StreamDialogEntry.append_playlist,
- StreamDialogEntry.share
- ));
- } else {
- entries.addAll(Arrays.asList(
- StreamDialogEntry.start_here_on_background,
- StreamDialogEntry.start_here_on_popup,
- StreamDialogEntry.delete,
- StreamDialogEntry.append_playlist,
- StreamDialogEntry.share
- ));
- }
- entries.add(StreamDialogEntry.open_in_browser);
- if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
- entries.add(StreamDialogEntry.play_with_kodi);
- }
-
- // show "mark as watched" only when watch history is enabled
- if (StreamDialogEntry.shouldAddMarkAsWatched(
- item.getStreamEntity().getStreamType(),
- context
- )) {
- entries.add(
- StreamDialogEntry.mark_as_watched
- );
- }
- entries.add(StreamDialogEntry.show_channel_details);
-
- StreamDialogEntry.setEnabledEntries(entries);
-
- StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItemDuplicate) ->
- NavigationHelper
- .playOnBackgroundPlayer(context, getPlayQueueStartingAt(item), true));
- StreamDialogEntry.delete.setCustomAction((fragment, infoItemDuplicate) ->
- deleteEntry(Math.max(itemListAdapter.getItemsList().indexOf(item), 0)));
-
- new InfoItemDialog(activity, infoItem, StreamDialogEntry.getCommands(context),
- (dialog, which) -> StreamDialogEntry.clickOn(which, this, infoItem)).show();
}
private void deleteEntry(final int index) {
diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
index feb5b2f96..0eb56d716 100644
--- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
@@ -1,6 +1,8 @@
package org.schabi.newpipe.local.playlist;
-import android.app.Activity;
+import static org.schabi.newpipe.ktx.ViewUtils.animate;
+import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
+
import android.content.Context;
import android.content.DialogInterface;
import android.os.Bundle;
@@ -38,22 +40,18 @@ import org.schabi.newpipe.databinding.PlaylistControlBinding;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
-import org.schabi.newpipe.extractor.stream.StreamType;
-import org.schabi.newpipe.info_list.InfoItemDialog;
+import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.player.MainPlayer.PlayerType;
-import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
-import org.schabi.newpipe.util.external_communication.KoreUtils;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
-import org.schabi.newpipe.util.StreamDialogEntry;
+import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
@@ -68,9 +66,6 @@ import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
import io.reactivex.rxjava3.subjects.PublishSubject;
-import static org.schabi.newpipe.ktx.ViewUtils.animate;
-import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
-
public class LocalPlaylistFragment extends BaseLocalListFragment, Void> {
// Save the list 10 seconds after the last change occurred
private static final long SAVE_DEBOUNCE_MILLIS = 10000;
@@ -182,7 +177,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment removeWatchedStreams(false))
.setNeutralButton(
R.string.remove_watched_popup_yes_and_partially_watched_videos,
@@ -743,70 +738,39 @@ public class LocalPlaylistFragment extends BaseLocalListFragment entries = new ArrayList<>();
+ try {
+ final Context context = getContext();
+ final InfoItemDialog.Builder dialogBuilder =
+ new InfoItemDialog.Builder(getActivity(), context, this, infoItem);
- if (PlayerHolder.getInstance().isPlayQueueReady()) {
- entries.add(StreamDialogEntry.enqueue);
-
- if (PlayerHolder.getInstance().getQueueSize() > 1) {
- entries.add(StreamDialogEntry.enqueue_next);
- }
- }
- if (infoItem.getStreamType() == StreamType.AUDIO_STREAM) {
- entries.addAll(Arrays.asList(
- StreamDialogEntry.start_here_on_background,
- StreamDialogEntry.set_as_playlist_thumbnail,
- StreamDialogEntry.delete,
- StreamDialogEntry.append_playlist,
- StreamDialogEntry.share
- ));
- } else {
- entries.addAll(Arrays.asList(
- StreamDialogEntry.start_here_on_background,
- StreamDialogEntry.start_here_on_popup,
- StreamDialogEntry.set_as_playlist_thumbnail,
- StreamDialogEntry.delete,
- StreamDialogEntry.append_playlist,
- StreamDialogEntry.share
- ));
- }
- entries.add(StreamDialogEntry.open_in_browser);
- if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
- entries.add(StreamDialogEntry.play_with_kodi);
- }
-
- // show "mark as watched" only when watch history is enabled
- if (StreamDialogEntry.shouldAddMarkAsWatched(
- item.getStreamEntity().getStreamType(),
- context
- )) {
- entries.add(
- StreamDialogEntry.mark_as_watched
+ // add entries in the middle
+ dialogBuilder.addAllEntries(
+ StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL,
+ StreamDialogDefaultEntry.DELETE
);
+
+ // set custom actions
+ // all entries modified below have already been added within the builder
+ dialogBuilder
+ .setAction(
+ StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND,
+ (f, i) -> NavigationHelper.playOnBackgroundPlayer(
+ context, getPlayQueueStartingAt(item), true))
+ .setAction(
+ StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL,
+ (f, i) ->
+ changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl()))
+ .setAction(
+ StreamDialogDefaultEntry.DELETE,
+ (f, i) -> deleteItem(item))
+ .create()
+ .show();
+ } catch (final IllegalArgumentException e) {
+ InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem);
}
- entries.add(StreamDialogEntry.show_channel_details);
-
- StreamDialogEntry.setEnabledEntries(entries);
-
- StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItemDuplicate) ->
- NavigationHelper.playOnBackgroundPlayer(context,
- getPlayQueueStartingAt(item), true));
- StreamDialogEntry.set_as_playlist_thumbnail.setCustomAction(
- (fragment, infoItemDuplicate) ->
- changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl()));
- StreamDialogEntry.delete.setCustomAction((fragment, infoItemDuplicate) ->
- deleteItem(item));
-
- new InfoItemDialog(activity, infoItem, StreamDialogEntry.getCommands(context),
- (dialog, which) -> StreamDialogEntry.clickOn(which, this, infoItem)).show();
}
private void setInitialData(final long pid, final String title) {
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java
index a3d8b0567..da8e1070a 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java
@@ -61,7 +61,7 @@ public class ImportConfirmationDialog extends DialogFragment {
}
@Override
- public void onSaveInstanceState(final Bundle outState) {
+ public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState);
Icepick.saveInstanceState(this, outState);
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt
index fb9cffa98..b17f49801 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt
@@ -7,6 +7,8 @@ import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.database.stream.model.StreamEntity
+import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionDAO
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.extractor.ListInfo
@@ -14,6 +16,7 @@ import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.feed.FeedInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.FeedDatabaseManager
+import org.schabi.newpipe.util.ExtractorHelper
class SubscriptionManager(context: Context) {
private val database = NewPipeDatabase.getInstance(context)
@@ -66,13 +69,33 @@ class SubscriptionManager(context: Context) {
}
}
+ fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable {
+ return subscriptionTable().getSubscription(serviceId, url)
+ .flatMapCompletable { entity: SubscriptionEntity ->
+ Completable.fromAction {
+ entity.notificationMode = mode
+ subscriptionTable().update(entity)
+ }.apply {
+ if (mode != NotificationMode.DISABLED) {
+ // notifications have just been enabled, mark all streams as "old"
+ andThen(rememberAllStreams(entity))
+ }
+ }
+ }
+ }
+
fun updateFromInfo(subscriptionId: Long, info: ListInfo) {
val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId)
if (info is FeedInfo) {
subscriptionEntity.name = info.name
} else if (info is ChannelInfo) {
- subscriptionEntity.setData(info.name, info.avatarUrl, info.description, info.subscriberCount)
+ subscriptionEntity.setData(
+ info.name,
+ info.avatarUrl,
+ info.description,
+ info.subscriberCount
+ )
}
subscriptionTable.update(subscriptionEntity)
@@ -94,4 +117,19 @@ class SubscriptionManager(context: Context) {
fun deleteSubscription(subscriptionEntity: SubscriptionEntity) {
subscriptionTable.delete(subscriptionEntity)
}
+
+ /**
+ * Fetches the list of videos for the provided channel and saves them in the database, so that
+ * they will be considered as "old"/"already seen" streams and the user will never be notified
+ * about any one of them.
+ */
+ private fun rememberAllStreams(subscription: SubscriptionEntity): Completable {
+ return ExtractorHelper.getChannelInfo(subscription.serviceId, subscription.url, false)
+ .map { channel -> channel.relatedItems.map { stream -> StreamEntity(stream) } }
+ .flatMapCompletable { entities ->
+ Completable.fromAction {
+ database.streamDAO().upsertAll(entities)
+ }
+ }.onErrorComplete()
+ }
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt
index d49df6303..54ba1c6dc 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt
@@ -8,7 +8,6 @@ import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.Disposable
-import io.reactivex.rxjava3.functions.BiFunction
import io.reactivex.rxjava3.processors.BehaviorProcessor
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
@@ -33,9 +32,8 @@ class FeedGroupDialogViewModel(
private var subscriptionsFlowable = Flowable
.combineLatest(
filterSubscriptions.startWithItem(initialQuery),
- toggleShowOnlyUngrouped.startWithItem(initialShowOnlyUngrouped),
- BiFunction { t1: String, t2: Boolean -> Filter(t1, t2) }
- )
+ toggleShowOnlyUngrouped.startWithItem(initialShowOnlyUngrouped)
+ ) { t1: String, t2: Boolean -> Filter(t1, t2) }
.distinctUntilChanged()
.switchMap { (query, showOnlyUngrouped) ->
subscriptionManager.getSubscriptions(groupId, query, showOnlyUngrouped)
@@ -56,9 +54,8 @@ class FeedGroupDialogViewModel(
private var subscriptionsDisposable = Flowable
.combineLatest(
- subscriptionsFlowable, feedDatabaseManager.subscriptionIdsForGroup(groupId),
- BiFunction { t1: List, t2: List -> t1 to t2.toSet() }
- )
+ subscriptionsFlowable, feedDatabaseManager.subscriptionIdsForGroup(groupId)
+ ) { t1: List, t2: List -> t1 to t2.toSet() }
.subscribeOn(Schedulers.io())
.subscribe(mutableSubscriptionsLiveData::postValue)
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt
index 50e8aae6a..1f3ab71eb 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt
@@ -57,15 +57,12 @@ class FeedGroupReorderDialog : DialogFragment() {
viewModel = ViewModelProvider(this).get(FeedGroupReorderDialogViewModel::class.java)
viewModel.groupsLiveData.observe(viewLifecycleOwner, Observer(::handleGroups))
- viewModel.dialogEventLiveData.observe(
- viewLifecycleOwner,
- Observer {
- when (it) {
- ProcessingEvent -> disableInput()
- SuccessEvent -> dismiss()
- }
+ viewModel.dialogEventLiveData.observe(viewLifecycleOwner) {
+ when (it) {
+ ProcessingEvent -> disableInput()
+ SuccessEvent -> dismiss()
}
- )
+ }
binding.feedGroupsList.layoutManager = LinearLayoutManager(requireContext())
binding.feedGroupsList.adapter = groupAdapter
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java
index 8e3aad893..611a1cd30 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java
@@ -25,7 +25,6 @@ import com.grack.nanojson.JsonAppendableWriter;
import com.grack.nanojson.JsonArray;
import com.grack.nanojson.JsonObject;
import com.grack.nanojson.JsonParser;
-import com.grack.nanojson.JsonSink;
import com.grack.nanojson.JsonWriter;
import org.schabi.newpipe.BuildConfig;
@@ -125,10 +124,11 @@ public final class ImportExportJsonHelper {
/**
* @see #writeTo(List, OutputStream, ImportExportEventListener)
* @param items the list of subscriptions items
- * @param writer the output {@link JsonSink}
+ * @param writer the output {@link JsonAppendableWriter}
* @param eventListener listener for the events generated
*/
- public static void writeTo(final List items, final JsonSink writer,
+ public static void writeTo(final List items,
+ final JsonAppendableWriter writer,
@Nullable final ImportExportEventListener eventListener) {
if (eventListener != null) {
eventListener.onSizeReceived(items.size());
diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java
index e0c5ab083..53e6ce591 100644
--- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java
@@ -1,7 +1,10 @@
package org.schabi.newpipe.player;
+import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu;
+import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed;
+import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
+
import android.content.ComponentName;
-import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Bundle;
@@ -23,11 +26,9 @@ import androidx.recyclerview.widget.RecyclerView;
import com.google.android.exoplayer2.PlaybackParameters;
import org.schabi.newpipe.R;
-import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.databinding.ActivityPlayerQueueControlBinding;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
-import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.event.PlayerEventListener;
import org.schabi.newpipe.player.helper.PlaybackParameterDialog;
import org.schabi.newpipe.player.playqueue.PlayQueue;
@@ -42,13 +43,6 @@ import org.schabi.newpipe.util.PermissionHelper;
import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.util.ThemeHelper;
-import java.util.List;
-import java.util.stream.Collectors;
-
-import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu;
-import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed;
-import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
-
public final class PlayQueueActivity extends AppCompatActivity
implements PlayerEventListener, SeekBar.OnSeekBarChangeListener,
View.OnClickListener, PlaybackParameterDialog.Callback {
@@ -129,7 +123,7 @@ public final class PlayQueueActivity extends AppCompatActivity
NavigationHelper.openSettings(this);
return true;
case R.id.action_append_playlist:
- appendAllToPlaylist();
+ player.onAddToPlaylistClicked(getSupportFragmentManager());
return true;
case R.id.action_playback_speed:
openPlaybackParameterDialog();
@@ -443,24 +437,6 @@ public final class PlayQueueActivity extends AppCompatActivity
seeking = false;
}
- ////////////////////////////////////////////////////////////////////////////
- // Playlist append
- ////////////////////////////////////////////////////////////////////////////
-
- private void appendAllToPlaylist() {
- if (player != null && player.getPlayQueue() != null) {
- openPlaylistAppendDialog(player.getPlayQueue().getStreams());
- }
- }
-
- private void openPlaylistAppendDialog(final List playQueueItems) {
- PlaylistDialog.createCorrespondingDialog(
- getApplicationContext(),
- playQueueItems.stream().map(StreamEntity::new).collect(Collectors.toList()),
- dialog -> dialog.show(getSupportFragmentManager(), TAG)
- );
- }
-
////////////////////////////////////////////////////////////////////////////
// Binding Service Listener
////////////////////////////////////////////////////////////////////////////
@@ -624,7 +600,6 @@ public final class PlayQueueActivity extends AppCompatActivity
//2) Icon change accordingly to current App Theme
// using rootView.getContext() because getApplicationContext() didn't work
- final Context context = queueControlBinding.getRoot().getContext();
item.setIcon(player.isMuted() ? R.drawable.ic_volume_off : R.drawable.ic_volume_up);
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java
index 87d2bd34a..30c62af39 100644
--- a/app/src/main/java/org/schabi/newpipe/player/Player.java
+++ b/app/src/main/java/org/schabi/newpipe/player/Player.java
@@ -1,5 +1,21 @@
package org.schabi.newpipe.player;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_NO_PERMISSION;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_UNSPECIFIED;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_TIMEOUT;
+import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_UNSPECIFIED;
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_AUTO_TRANSITION;
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_INTERNAL;
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_REMOVE;
@@ -46,7 +62,6 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.savePlaybackParamete
import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex;
import static org.schabi.newpipe.util.ListHelper.getResolutionIndex;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
-import static org.schabi.newpipe.util.Localization.containsCaseInsensitive;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import android.animation.Animator;
@@ -73,7 +88,6 @@ import android.provider.Settings;
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.TypedValue;
-import android.view.ContextThemeWrapper;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.LayoutInflater;
@@ -89,7 +103,6 @@ import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
-import android.widget.PopupMenu;
import android.widget.ProgressBar;
import android.widget.RelativeLayout;
import android.widget.SeekBar;
@@ -99,30 +112,34 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.content.res.AppCompatResources;
+import androidx.appcompat.view.ContextThemeWrapper;
import androidx.appcompat.widget.AppCompatImageButton;
+import androidx.appcompat.widget.PopupMenu;
+import androidx.collection.ArraySet;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.Insets;
import androidx.core.view.GestureDetectorCompat;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
+import androidx.fragment.app.FragmentManager;
import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.DefaultRenderersFactory;
-import com.google.android.exoplayer2.ExoPlaybackException;
+import com.google.android.exoplayer2.ExoPlayer;
+import com.google.android.exoplayer2.Format;
+import com.google.android.exoplayer2.PlaybackException;
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player.PositionInfo;
import com.google.android.exoplayer2.RenderersFactory;
-import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.Timeline;
-import com.google.android.exoplayer2.source.BehindLiveWindowException;
+import com.google.android.exoplayer2.TracksInfo;
import com.google.android.exoplayer2.source.MediaSource;
-import com.google.android.exoplayer2.source.TrackGroup;
-import com.google.android.exoplayer2.source.TrackGroupArray;
import com.google.android.exoplayer2.text.Cue;
-import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
+import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
+import com.google.android.exoplayer2.trackselection.MappingTrackSelector;
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
import com.google.android.exoplayer2.ui.CaptionStyleCompat;
import com.google.android.exoplayer2.ui.SubtitleView;
@@ -136,19 +153,23 @@ import com.squareup.picasso.Target;
import org.schabi.newpipe.DownloaderImpl;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R;
+import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.databinding.PlayerBinding;
import org.schabi.newpipe.databinding.PlayerPopupCloseOverlayBinding;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.UserAction;
+import org.schabi.newpipe.extractor.Info;
import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamSegment;
+import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
import org.schabi.newpipe.info_list.StreamSegmentAdapter;
import org.schabi.newpipe.ktx.AnimationType;
+import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.player.MainPlayer.PlayerType;
import org.schabi.newpipe.player.event.DisplayPortion;
@@ -158,10 +179,11 @@ import org.schabi.newpipe.player.event.PlayerServiceEventListener;
import org.schabi.newpipe.player.helper.AudioReactor;
import org.schabi.newpipe.player.helper.LoadController;
import org.schabi.newpipe.player.helper.MediaSessionManager;
-import org.schabi.newpipe.player.helper.PlaybackParameterDialog;
import org.schabi.newpipe.player.helper.PlayerDataSource;
import org.schabi.newpipe.player.helper.PlayerHelper;
-import org.schabi.newpipe.player.playback.CustomTrackSelector;
+import org.schabi.newpipe.player.listeners.view.PlaybackSpeedClickListener;
+import org.schabi.newpipe.player.listeners.view.QualityClickListener;
+import org.schabi.newpipe.player.mediaitem.MediaItemTag;
import org.schabi.newpipe.player.playback.MediaSourceManager;
import org.schabi.newpipe.player.playback.PlaybackListener;
import org.schabi.newpipe.player.playback.PlayerMediaSession;
@@ -173,8 +195,8 @@ import org.schabi.newpipe.player.playqueue.PlayQueueItemBuilder;
import org.schabi.newpipe.player.playqueue.PlayQueueItemHolder;
import org.schabi.newpipe.player.playqueue.PlayQueueItemTouchCallback;
import org.schabi.newpipe.player.resolver.AudioPlaybackResolver;
-import org.schabi.newpipe.player.resolver.MediaSourceTag;
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver;
+import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.SourceType;
import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper;
import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHolder;
import org.schabi.newpipe.util.DeviceUtils;
@@ -188,11 +210,12 @@ import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.views.ExpandableSurfaceView;
import org.schabi.newpipe.views.player.PlayerFastSeekOverlay;
-import java.io.IOException;
-import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
@@ -268,19 +291,19 @@ public final class Player implements
@Nullable private MediaSourceManager playQueueManager;
@Nullable private PlayQueueItem currentItem;
- @Nullable private MediaSourceTag currentMetadata;
+ @Nullable private MediaItemTag currentMetadata;
@Nullable private Bitmap currentThumbnail;
/*//////////////////////////////////////////////////////////////////////////
// Player
//////////////////////////////////////////////////////////////////////////*/
- private SimpleExoPlayer simpleExoPlayer;
+ private ExoPlayer simpleExoPlayer;
private AudioReactor audioReactor;
private MediaSessionManager mediaSessionManager;
@Nullable private SurfaceHolderCallback surfaceHolderCallback;
- @NonNull private final CustomTrackSelector trackSelector;
+ @NonNull private final DefaultTrackSelector trackSelector;
@NonNull private final LoadController loadController;
@NonNull private final RenderersFactory renderFactory;
@@ -405,7 +428,7 @@ public final class Player implements
setupBroadcastReceiver();
- trackSelector = new CustomTrackSelector(context, PlayerHelper.getQualitySelector());
+ trackSelector = new DefaultTrackSelector(context, PlayerHelper.getQualitySelector());
final PlayerDataSource dataSource = new PlayerDataSource(context, DownloaderImpl.USER_AGENT,
new DefaultBandwidthMeter.Builder(context).build());
loadController = new LoadController();
@@ -488,7 +511,7 @@ public final class Player implements
Log.d(TAG, "initPlayer() called with: playOnReady = [" + playOnReady + "]");
}
- simpleExoPlayer = new SimpleExoPlayer.Builder(context, renderFactory)
+ simpleExoPlayer = new ExoPlayer.Builder(context, renderFactory)
.setTrackSelector(trackSelector)
.setLoadControl(loadController)
.build();
@@ -521,9 +544,12 @@ public final class Player implements
}
private void initListeners() {
+ binding.qualityTextView.setOnClickListener(
+ new QualityClickListener(this, qualityPopupMenu));
+ binding.playbackSpeed.setOnClickListener(
+ new PlaybackSpeedClickListener(this, playbackSpeedPopupMenu));
+
binding.playbackSeekBar.setOnSeekBarChangeListener(this);
- binding.playbackSpeed.setOnClickListener(this);
- binding.qualityTextView.setOnClickListener(this);
binding.captionTextView.setOnClickListener(this);
binding.resizeTextView.setOnClickListener(this);
binding.playbackLiveSync.setOnClickListener(this);
@@ -532,10 +558,15 @@ public final class Player implements
gestureDetector = new GestureDetectorCompat(context, playerGestureListener);
binding.getRoot().setOnTouchListener(playerGestureListener);
- binding.queueButton.setOnClickListener(this);
- binding.segmentsButton.setOnClickListener(this);
- binding.repeatButton.setOnClickListener(this);
- binding.shuffleButton.setOnClickListener(this);
+ binding.queueButton.setOnClickListener(v -> onQueueClicked());
+ binding.segmentsButton.setOnClickListener(v -> onSegmentsClicked());
+ binding.repeatButton.setOnClickListener(v -> onRepeatClicked());
+ binding.shuffleButton.setOnClickListener(v -> onShuffleClicked());
+ binding.addToPlaylistButton.setOnClickListener(v -> {
+ if (getParentActivity() != null) {
+ onAddToPlaylistClicked(getParentActivity().getSupportFragmentManager());
+ }
+ });
binding.playPauseButton.setOnClickListener(this);
binding.playPreviousButton.setOnClickListener(this);
@@ -664,6 +695,7 @@ public final class Player implements
//////////////////////////////////////////////////////////////////////////*/
//region Playback initialization via intent
+ @SuppressWarnings("MethodLength")
public void handleIntent(@NonNull final Intent intent) {
// fail fast if no play queue was provided
final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY);
@@ -1623,8 +1655,7 @@ public final class Player implements
}
public boolean getPlaybackSkipSilence() {
- return !exoPlayerIsNull() && simpleExoPlayer.getAudioComponent() != null
- && simpleExoPlayer.getAudioComponent().getSkipSilenceEnabled();
+ return !exoPlayerIsNull() && simpleExoPlayer.getSkipSilenceEnabled();
}
public PlaybackParameters getPlaybackParameters() {
@@ -1650,9 +1681,7 @@ public final class Player implements
savePlaybackParametersToPrefs(this, roundedSpeed, roundedPitch, skipSilence);
simpleExoPlayer.setPlaybackParameters(
new PlaybackParameters(roundedSpeed, roundedPitch));
- if (simpleExoPlayer.getAudioComponent() != null) {
- simpleExoPlayer.getAudioComponent().setSkipSilenceEnabled(skipSilence);
- }
+ simpleExoPlayer.setSkipSilenceEnabled(skipSilence);
}
//endregion
@@ -1916,7 +1945,7 @@ public final class Player implements
}, delay);
}
- private void showHideShadow(final boolean show, final long duration) {
+ public void showHideShadow(final boolean show, final long duration) {
animate(binding.playbackControlsShadow, show, duration, AnimationType.ALPHA, 0, null);
animate(binding.playerTopShadow, show, duration, AnimationType.ALPHA, 0, null);
animate(binding.playerBottomShadow, show, duration, AnimationType.ALPHA, 0, null);
@@ -1930,11 +1959,12 @@ public final class Player implements
final boolean showPrev = playQueue.getIndex() != 0;
final boolean showNext = playQueue.getIndex() + 1 != playQueue.getStreams().size();
final boolean showQueue = playQueue.getStreams().size() > 1 && !popupPlayerSelected();
- boolean showSegment = false;
- if (currentMetadata != null) {
- showSegment = !currentMetadata.getMetadata().getStreamSegments().isEmpty()
- && !popupPlayerSelected();
- }
+ /* only when stream has segments and is not playing in popup player */
+ final boolean showSegment = !popupPlayerSelected()
+ && !getCurrentStreamInfo()
+ .map(StreamInfo::getStreamSegments)
+ .map(List::isEmpty)
+ .orElse(/*no stream info=*/true);
binding.playPreviousButton.setVisibility(showPrev ? View.VISIBLE : View.INVISIBLE);
binding.playPreviousButton.setAlpha(showPrev ? 1.0f : 0.0f);
@@ -1974,9 +2004,29 @@ public final class Player implements
// Playback states
//////////////////////////////////////////////////////////////////////////*/
//region Playback states
+ @Override
+ public void onPlayWhenReadyChanged(final boolean playWhenReady, final int reason) {
+ if (DEBUG) {
+ Log.d(TAG, "ExoPlayer - onPlayWhenReadyChanged() called with: "
+ + "playWhenReady = [" + playWhenReady + "], "
+ + "reason = [" + reason + "]");
+ }
+ final int playbackState = exoPlayerIsNull()
+ ? com.google.android.exoplayer2.Player.STATE_IDLE
+ : simpleExoPlayer.getPlaybackState();
+ updatePlaybackState(playWhenReady, playbackState);
+ }
- @Override // exoplayer listener
- public void onPlayerStateChanged(final boolean playWhenReady, final int playbackState) {
+ @Override
+ public void onPlaybackStateChanged(final int playbackState) {
+ if (DEBUG) {
+ Log.d(TAG, "ExoPlayer - onPlaybackStateChanged() called with: "
+ + "playbackState = [" + playbackState + "]");
+ }
+ updatePlaybackState(getPlayWhenReady(), playbackState);
+ }
+
+ private void updatePlaybackState(final boolean playWhenReady, final int playbackState) {
if (DEBUG) {
Log.d(TAG, "ExoPlayer - onPlayerStateChanged() called with: "
+ "playWhenReady = [" + playWhenReady + "], "
@@ -1985,7 +2035,7 @@ public final class Player implements
if (currentState == STATE_PAUSED_SEEK) {
if (DEBUG) {
- Log.d(TAG, "ExoPlayer - onPlayerStateChanged() is currently blocked");
+ Log.d(TAG, "updatePlaybackState() is currently blocked");
}
return;
}
@@ -2000,8 +2050,6 @@ public final class Player implements
}
break;
case com.google.android.exoplayer2.Player.STATE_READY: //3
- maybeUpdateCurrentMetadata();
- maybeCorrectSeekPosition();
if (!isPrepared) {
isPrepared = true;
onPrepared(playWhenReady);
@@ -2018,18 +2066,11 @@ public final class Player implements
@Override // exoplayer listener
public void onIsLoadingChanged(final boolean isLoading) {
- if (DEBUG) {
- Log.d(TAG, "ExoPlayer - onLoadingChanged() called with: "
- + "isLoading = [" + isLoading + "]");
- }
-
if (!isLoading && currentState == STATE_PAUSED && isProgressLoopRunning()) {
stopProgressLoop();
} else if (isLoading && !isProgressLoopRunning()) {
startProgressLoop();
}
-
- maybeUpdateCurrentMetadata();
}
@Override // own playback listener
@@ -2384,6 +2425,32 @@ public final class Player implements
+ /*//////////////////////////////////////////////////////////////////////////
+ // Playlist append
+ //////////////////////////////////////////////////////////////////////////*/
+ //region Playlist append
+
+ public void onAddToPlaylistClicked(@NonNull final FragmentManager fragmentManager) {
+ if (DEBUG) {
+ Log.d(TAG, "onAddToPlaylistClicked() called");
+ }
+
+ if (getPlayQueue() != null) {
+ PlaylistDialog.createCorrespondingDialog(
+ getContext(),
+ getPlayQueue()
+ .getStreams()
+ .stream()
+ .map(StreamEntity::new)
+ .collect(Collectors.toList()),
+ dialog -> dialog.show(fragmentManager, TAG)
+ );
+ }
+ }
+ //endregion
+
+
+
/*//////////////////////////////////////////////////////////////////////////
// Mute / Unmute
//////////////////////////////////////////////////////////////////////////*/
@@ -2415,28 +2482,52 @@ public final class Player implements
//////////////////////////////////////////////////////////////////////////*/
//region ExoPlayer listeners (that didn't fit in other categories)
+ /**
+ *
Listens for event or state changes on ExoPlayer. When any event happens, we check for
+ * changes in the currently-playing metadata and update the encapsulating
+ * {@link Player}. Downstream listeners are also informed.
+ *
+ *
When the renewed metadata contains any error, it is reported as a notification.
+ * This is done because not all source resolution errors are {@link PlaybackException}, which
+ * are also captured by {@link ExoPlayer} and stops the playback.
+ *
+ * @param player The {@link com.google.android.exoplayer2.Player} whose state changed.
+ * @param events The {@link com.google.android.exoplayer2.Player.Events} that has triggered
+ * the player state changes.
+ **/
@Override
- public void onTimelineChanged(@NonNull final Timeline timeline, final int reason) {
- if (DEBUG) {
- Log.d(TAG, "ExoPlayer - onTimelineChanged() called with "
- + "timeline size = [" + timeline.getWindowCount() + "], "
- + "reason = [" + reason + "]");
- }
-
- maybeUpdateCurrentMetadata();
- // force recreate notification to ensure seek bar is shown when preparation finishes
- NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, true);
+ public void onEvents(@NonNull final com.google.android.exoplayer2.Player player,
+ @NonNull final com.google.android.exoplayer2.Player.Events events) {
+ Listener.super.onEvents(player, events);
+ MediaItemTag.from(player.getCurrentMediaItem()).ifPresent(tag -> {
+ if (tag == currentMetadata) {
+ return;
+ }
+ currentMetadata = tag;
+ if (!tag.getErrors().isEmpty()) {
+ final ErrorInfo errorInfo = new ErrorInfo(
+ tag.getErrors().get(0),
+ UserAction.PLAY_STREAM,
+ "Loading failed for [" + tag.getTitle() + "]: " + tag.getStreamUrl(),
+ tag.getServiceId());
+ ErrorUtil.createNotification(context, errorInfo);
+ }
+ tag.getMaybeStreamInfo().ifPresent(info -> {
+ if (DEBUG) {
+ Log.d(TAG, "ExoPlayer - onEvents() update stream info: " + info.getName());
+ }
+ updateMetadataWith(info);
+ });
+ });
}
@Override
- public void onTracksChanged(@NonNull final TrackGroupArray trackGroups,
- @NonNull final TrackSelectionArray trackSelections) {
+ public void onTracksInfoChanged(@NonNull final TracksInfo tracksInfo) {
if (DEBUG) {
Log.d(TAG, "ExoPlayer - onTracksChanged(), "
- + "track group size = " + trackGroups.length);
+ + "track group size = " + tracksInfo.getTrackGroupInfos().size());
}
- maybeUpdateCurrentMetadata();
- onTextTracksChanged();
+ onTextTracksChanged(tracksInfo);
}
@Override
@@ -2449,11 +2540,15 @@ public final class Player implements
}
@Override
- public void onPositionDiscontinuity(
- final PositionInfo oldPosition, final PositionInfo newPosition,
- @DiscontinuityReason final int discontinuityReason) {
+ public void onPositionDiscontinuity(@NonNull final PositionInfo oldPosition,
+ @NonNull final PositionInfo newPosition,
+ @DiscontinuityReason final int discontinuityReason) {
if (DEBUG) {
Log.d(TAG, "ExoPlayer - onPositionDiscontinuity() called with "
+ + "oldPositionIndex = [" + oldPosition.mediaItemIndex + "], "
+ + "oldPositionMs = [" + oldPosition.positionMs + "], "
+ + "newPositionIndex = [" + newPosition.mediaItemIndex + "], "
+ + "newPositionMs = [" + newPosition.positionMs + "], "
+ "discontinuityReason = [" + discontinuityReason + "]");
}
if (playQueue == null) {
@@ -2461,13 +2556,13 @@ public final class Player implements
}
// Refresh the playback if there is a transition to the next video
- final int newWindowIndex = simpleExoPlayer.getCurrentWindowIndex();
+ final int newIndex = newPosition.mediaItemIndex;
switch (discontinuityReason) {
case DISCONTINUITY_REASON_AUTO_TRANSITION:
case DISCONTINUITY_REASON_REMOVE:
// When player is in single repeat mode and a period transition occurs,
// we need to register a view count here since no metadata has changed
- if (getRepeatMode() == REPEAT_MODE_ONE && newWindowIndex == playQueue.getIndex()) {
+ if (getRepeatMode() == REPEAT_MODE_ONE && newIndex == playQueue.getIndex()) {
registerStreamViewed();
break;
}
@@ -2480,16 +2575,15 @@ public final class Player implements
}
case DISCONTINUITY_REASON_SEEK_ADJUSTMENT:
case DISCONTINUITY_REASON_INTERNAL:
- if (playQueue.getIndex() != newWindowIndex) {
+ // Player index may be invalid when playback is blocked
+ if (getCurrentState() != STATE_BLOCKED && newIndex != playQueue.getIndex()) {
saveStreamProgressStateCompleted(); // current stream has ended
- playQueue.setIndex(newWindowIndex);
+ playQueue.setIndex(newIndex);
}
break;
case DISCONTINUITY_REASON_SKIP:
break; // only makes Android Studio linter happy, as there are no ads
}
-
- maybeUpdateCurrentMetadata();
}
@Override
@@ -2499,7 +2593,7 @@ public final class Player implements
}
@Override
- public void onCues(final List cues) {
+ public void onCues(@NonNull final List cues) {
binding.subtitleView.onCues(cues);
}
//endregion
@@ -2514,94 +2608,103 @@ public final class Player implements
* Process exceptions produced by {@link com.google.android.exoplayer2.ExoPlayer ExoPlayer}.
*
{@link ExoPlaybackException#TYPE_UNEXPECTED TYPE_UNEXPECTED}:
- * If a runtime error occurred, then we can try to recover it by restarting the playback
- * after setting the timestamp recovery.
- *
{@link ExoPlaybackException#TYPE_RENDERER TYPE_RENDERER}:
- * If the renderer failed, treat the error as unrecoverable.
+ *
{@link PlaybackException#ERROR_CODE_BEHIND_LIVE_WINDOW BEHIND_LIVE_WINDOW}:
+ * If the playback on livestreams are lagged too far behind the current playable
+ * window. Then we seek to the latest timestamp and restart the playback.
+ * This error is catchable.
+ *
+ *
From {@link PlaybackException#ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE BAD_IO} to
+ * {@link PlaybackException#ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED UNSUPPORTED_FORMATS}:
+ * If the stream source is validated by the extractor but not recognized by the player,
+ * then we can try to recover playback by signalling an error on the {@link PlayQueue}.
+ *
For {@link PlaybackException#ERROR_CODE_TIMEOUT PLAYER_TIMEOUT},
+ * {@link PlaybackException#ERROR_CODE_IO_UNSPECIFIED MEDIA_SOURCE_RESOLVER_TIMEOUT} and
+ * {@link PlaybackException#ERROR_CODE_IO_NETWORK_CONNECTION_FAILED NO_NETWORK}:
+ * We can keep set the recovery record and keep to player at the current state until
+ * it is ready to play by restarting the {@link MediaSourceManager}.
+ *
On any ExoPlayer specific issue internal to its device interaction, such as
+ * {@link PlaybackException#ERROR_CODE_DECODER_INIT_FAILED DECODER_ERROR}:
+ * We terminate the playback.
+ *
For any other unspecified issue internal: We set a recovery and try to restart
+ * the playback.
+ * For any error above that is not explicitly catchable, the player will
+ * create a notification so users are aware.
*
- *
- * @see #processSourceError(IOException)
- * @see com.google.android.exoplayer2.Player.Listener#onPlayerError(ExoPlaybackException)
- */
+ * @see com.google.android.exoplayer2.Player.Listener#onPlayerError(PlaybackException)
+ * */
+ // Any error code not explicitly covered here are either unrelated to NewPipe use case
+ // (e.g. DRM) or not recoverable (e.g. Decoder error). In both cases, the player should
+ // shutdown.
+ @SuppressLint("SwitchIntDef")
@Override
- public void onPlayerError(@NonNull final ExoPlaybackException error) {
+ public void onPlayerError(@NonNull final PlaybackException error) {
Log.e(TAG, "ExoPlayer - onPlayerError() called with:", error);
saveStreamProgressState();
boolean isCatchableException = false;
- switch (error.type) {
- case ExoPlaybackException.TYPE_SOURCE:
- isCatchableException = processSourceError(error.getSourceException());
+ switch (error.errorCode) {
+ case ERROR_CODE_BEHIND_LIVE_WINDOW:
+ isCatchableException = true;
+ simpleExoPlayer.seekToDefaultPosition();
+ simpleExoPlayer.prepare();
+ // Inform the user that we are reloading the stream by
+ // switching to the buffering state
+ onBuffering();
break;
- case ExoPlaybackException.TYPE_UNEXPECTED:
+ case ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE:
+ case ERROR_CODE_IO_BAD_HTTP_STATUS:
+ case ERROR_CODE_IO_FILE_NOT_FOUND:
+ case ERROR_CODE_IO_NO_PERMISSION:
+ case ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED:
+ case ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE:
+ case ERROR_CODE_PARSING_CONTAINER_MALFORMED:
+ case ERROR_CODE_PARSING_MANIFEST_MALFORMED:
+ case ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED:
+ case ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED:
+ // Source errors, signal on playQueue and move on:
+ if (!exoPlayerIsNull() && playQueue != null) {
+ playQueue.error();
+ }
+ break;
+ case ERROR_CODE_TIMEOUT:
+ case ERROR_CODE_IO_UNSPECIFIED:
+ case ERROR_CODE_IO_NETWORK_CONNECTION_FAILED:
+ case ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT:
+ case ERROR_CODE_UNSPECIFIED:
+ // Reload playback on unexpected errors:
setRecovery();
reloadPlayQueueManager();
break;
- case ExoPlaybackException.TYPE_REMOTE:
- case ExoPlaybackException.TYPE_RENDERER:
default:
+ // API, remote and renderer errors belong here:
onPlaybackShutdown();
break;
}
- if (isCatchableException) {
- return;
+ if (!isCatchableException) {
+ createErrorNotification(error);
}
- createErrorNotification(error);
-
if (fragmentListener != null) {
- fragmentListener.onPlayerError(error);
+ fragmentListener.onPlayerError(error, isCatchableException);
}
}
- private void createErrorNotification(@NonNull final ExoPlaybackException error) {
+ private void createErrorNotification(@NonNull final PlaybackException error) {
final ErrorInfo errorInfo;
if (currentMetadata == null) {
errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM,
- "Player error[type=" + error.type + "] occurred, currentMetadata is null");
+ "Player error[type=" + error.getErrorCodeName()
+ + "] occurred, currentMetadata is null");
} else {
errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM,
- "Player error[type=" + error.type + "] occurred while playing "
- + currentMetadata.getMetadata().getUrl(),
- currentMetadata.getMetadata());
+ "Player error[type=" + error.getErrorCodeName()
+ + "] occurred while playing " + currentMetadata.getStreamUrl(),
+ currentMetadata.getServiceId());
}
ErrorUtil.createNotification(context, errorInfo);
}
-
- /**
- * Process an {@link IOException} returned by {@link ExoPlaybackException#getSourceException()}
- * for {@link ExoPlaybackException#TYPE_SOURCE} exceptions.
- *
- *
- * This method sets the recovery position and sends an error message to the play queue if the
- * exception is not a {@link BehindLiveWindowException}.
- *
- * @param error the source error which was thrown by ExoPlayer
- * @return whether the exception thrown is a {@link BehindLiveWindowException} ({@code false}
- * is always returned if ExoPlayer or the play queue is null)
- */
- private boolean processSourceError(final IOException error) {
- if (exoPlayerIsNull() || playQueue == null) {
- return false;
- }
-
- setRecovery();
-
- if (error instanceof BehindLiveWindowException) {
- simpleExoPlayer.seekToDefaultPosition();
- simpleExoPlayer.prepare();
- // Inform the user that we are reloading the stream by switching to the buffering state
- onBuffering();
- return true;
- }
-
- playQueue.error();
- return false;
- }
//endregion
@@ -2648,7 +2751,7 @@ public final class Player implements
}
final Timeline currentTimeline = simpleExoPlayer.getCurrentTimeline();
- final int currentWindowIndex = simpleExoPlayer.getCurrentWindowIndex();
+ final int currentWindowIndex = simpleExoPlayer.getCurrentMediaItemIndex();
if (currentTimeline.isEmpty() || currentWindowIndex < 0
|| currentWindowIndex >= currentTimeline.getWindowCount()) {
return false;
@@ -2660,20 +2763,19 @@ public final class Player implements
}
@Override // own playback listener
- public void onPlaybackSynchronize(@NonNull final PlayQueueItem item) {
+ public void onPlaybackSynchronize(@NonNull final PlayQueueItem item, final boolean wasBlocked) {
if (DEBUG) {
- Log.d(TAG, "Playback - onPlaybackSynchronize() called with "
- + "item=[" + item.getTitle() + "], url=[" + item.getUrl() + "]");
+ Log.d(TAG, "Playback - onPlaybackSynchronize(was blocked: " + wasBlocked
+ + ") called with item=[" + item.getTitle() + "], url=[" + item.getUrl() + "]");
}
if (exoPlayerIsNull() || playQueue == null) {
return;
}
- final boolean onPlaybackInitial = currentItem == null;
final boolean hasPlayQueueItemChanged = currentItem != item;
final int currentPlayQueueIndex = playQueue.indexOf(item);
- final int currentPlaylistIndex = simpleExoPlayer.getCurrentWindowIndex();
+ final int currentPlaylistIndex = simpleExoPlayer.getCurrentMediaItemIndex();
final int currentPlaylistSize = simpleExoPlayer.getCurrentTimeline().getWindowCount();
// If nothing to synchronize
@@ -2695,8 +2797,7 @@ public final class Player implements
+ "index=[" + currentPlayQueueIndex + "] with "
+ "playlist length=[" + currentPlaylistSize + "]");
- } else if (currentPlaylistIndex != currentPlayQueueIndex || onPlaybackInitial
- || !isPlaying()) {
+ } else if (wasBlocked || currentPlaylistIndex != currentPlayQueueIndex || !isPlaying()) {
if (DEBUG) {
Log.d(TAG, "Playback - Rewinding to correct "
+ "index=[" + currentPlayQueueIndex + "], "
@@ -2713,28 +2814,6 @@ public final class Player implements
}
}
- private void maybeCorrectSeekPosition() {
- if (playQueue == null || exoPlayerIsNull() || currentMetadata == null) {
- return;
- }
-
- final PlayQueueItem currentSourceItem = playQueue.getItem();
- if (currentSourceItem == null) {
- return;
- }
-
- final StreamInfo currentInfo = currentMetadata.getMetadata();
- final long presetStartPositionMillis = currentInfo.getStartPosition() * 1000;
- if (presetStartPositionMillis > 0L) {
- // Has another start position?
- if (DEBUG) {
- Log.d(TAG, "Playback - Seeking to preset start "
- + "position=[" + presetStartPositionMillis + "]");
- }
- seekTo(presetStartPositionMillis);
- }
- }
-
public void seekTo(final long positionMillis) {
if (DEBUG) {
Log.d(TAG, "seekBy() called with: position = [" + positionMillis + "]");
@@ -2896,24 +2975,22 @@ public final class Player implements
//region StreamInfo history: views and progress
private void registerStreamViewed() {
- if (currentMetadata != null) {
- databaseUpdateDisposable.add(recordManager.onViewed(currentMetadata.getMetadata())
- .onErrorComplete().subscribe());
- }
+ getCurrentStreamInfo().ifPresent(info -> databaseUpdateDisposable
+ .add(recordManager.onViewed(info).onErrorComplete().subscribe()));
}
private void saveStreamProgressState(final long progressMillis) {
- if (currentMetadata == null
+ if (!getCurrentStreamInfo().isPresent()
|| !prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) {
return;
}
if (DEBUG) {
Log.d(TAG, "saveStreamProgressState() called with: progressMillis=" + progressMillis
- + ", currentMetadata=[" + currentMetadata.getMetadata().getName() + "]");
+ + ", currentMetadata=[" + getCurrentStreamInfo().get().getName() + "]");
}
databaseUpdateDisposable
- .add(recordManager.saveStreamState(currentMetadata.getMetadata(), progressMillis)
+ .add(recordManager.saveStreamState(getCurrentStreamInfo().get(), progressMillis)
.observeOn(AndroidSchedulers.mainThread())
.doOnError(e -> {
if (DEBUG) {
@@ -2926,7 +3003,7 @@ public final class Player implements
public void saveStreamProgressState() {
if (exoPlayerIsNull() || currentMetadata == null || playQueue == null
- || playQueue.getIndex() != simpleExoPlayer.getCurrentWindowIndex()) {
+ || playQueue.getIndex() != simpleExoPlayer.getCurrentMediaItemIndex()) {
// Make sure play queue and current window index are equal, to prevent saving state for
// the wrong stream on discontinuity (e.g. when the stream just changed but the
// playQueue index and currentMetadata still haven't updated)
@@ -2939,10 +3016,9 @@ public final class Player implements
}
public void saveStreamProgressStateCompleted() {
- if (currentMetadata != null) {
- // current stream has ended, so the progress is its duration (+1 to overcome rounding)
- saveStreamProgressState((currentMetadata.getMetadata().getDuration() + 1) * 1000);
- }
+ // current stream has ended, so the progress is its duration (+1 to overcome rounding)
+ getCurrentStreamInfo().ifPresent(info ->
+ saveStreamProgressState((info.getDuration() + 1) * 1000));
}
//endregion
@@ -2953,8 +3029,7 @@ public final class Player implements
//////////////////////////////////////////////////////////////////////////*/
//region Metadata
- private void onMetadataChanged(@NonNull final MediaSourceTag tag) {
- final StreamInfo info = tag.getMetadata();
+ private void onMetadataChanged(@NonNull final StreamInfo info) {
if (DEBUG) {
Log.d(TAG, "Playback - onMetadataChanged() called, playing: " + info.getName());
}
@@ -2964,12 +3039,10 @@ public final class Player implements
updateStreamRelatedViews();
showHideKodiButton();
- binding.titleTextView.setText(tag.getMetadata().getName());
- binding.channelTextView.setText(tag.getMetadata().getUploaderName());
+ binding.titleTextView.setText(info.getName());
+ binding.channelTextView.setText(info.getUploaderName());
- this.seekbarPreviewThumbnailHolder.resetFrom(
- this.getContext(),
- tag.getMetadata().getPreviewFrames());
+ this.seekbarPreviewThumbnailHolder.resetFrom(this.getContext(), info.getPreviewFrames());
NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false);
@@ -2979,9 +3052,7 @@ public final class Player implements
getVideoTitle(),
getUploaderName(),
showThumbnail ? Optional.ofNullable(getThumbnail()) : Optional.empty(),
- StreamTypeUtil.isLiveStream(tag.getMetadata().getStreamType())
- ? -1
- : tag.getMetadata().getDuration()
+ StreamTypeUtil.isLiveStream(info.getStreamType()) ? -1 : info.getDuration()
);
notifyMetadataUpdateToListeners();
@@ -2998,39 +3069,21 @@ public final class Player implements
}
}
- private void maybeUpdateCurrentMetadata() {
+ private void updateMetadataWith(@NonNull final StreamInfo streamInfo) {
if (exoPlayerIsNull()) {
return;
}
- final MediaSourceTag metadata;
- try {
- metadata = (MediaSourceTag) simpleExoPlayer.getCurrentTag();
- } catch (IndexOutOfBoundsException | ClassCastException error) {
- if (DEBUG) {
- Log.d(TAG, "Could not update metadata: " + error.getMessage());
- error.printStackTrace();
- }
- return;
- }
-
- if (metadata == null) {
- return;
- }
- maybeAutoQueueNextStream(metadata);
-
- if (currentMetadata == metadata) {
- return;
- }
- currentMetadata = metadata;
- onMetadataChanged(metadata);
+ maybeAutoQueueNextStream(streamInfo);
+ onMetadataChanged(streamInfo);
+ NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, true);
}
@NonNull
private String getVideoUrl() {
return currentMetadata == null
? context.getString(R.string.unknown_content)
- : currentMetadata.getMetadata().getUrl();
+ : currentMetadata.getStreamUrl();
}
@NonNull
@@ -3038,7 +3091,7 @@ public final class Player implements
final int timeSeconds = binding.playbackSeekBar.getProgress() / 1000;
String videoUrl = getVideoUrl();
if (!isLive() && timeSeconds >= 0 && currentMetadata != null
- && currentMetadata.getMetadata().getServiceId() == YouTube.getServiceId()) {
+ && currentMetadata.getServiceId() == YouTube.getServiceId()) {
// Timestamp doesn't make sense in a live stream so drop it
videoUrl += ("&t=" + timeSeconds);
}
@@ -3049,14 +3102,14 @@ public final class Player implements
public String getVideoTitle() {
return currentMetadata == null
? context.getString(R.string.unknown_content)
- : currentMetadata.getMetadata().getName();
+ : currentMetadata.getTitle();
}
@NonNull
public String getUploaderName() {
return currentMetadata == null
? context.getString(R.string.unknown_content)
- : currentMetadata.getMetadata().getUploaderName();
+ : currentMetadata.getUploaderName();
}
@Nullable
@@ -3076,14 +3129,14 @@ public final class Player implements
//////////////////////////////////////////////////////////////////////////*/
//region Play queue, segments and streams
- private void maybeAutoQueueNextStream(@NonNull final MediaSourceTag metadata) {
+ private void maybeAutoQueueNextStream(@NonNull final StreamInfo info) {
if (playQueue == null || playQueue.getIndex() != playQueue.size() - 1
|| getRepeatMode() != REPEAT_MODE_OFF
|| !PlayerHelper.isAutoQueueEnabled(context)) {
return;
}
// auto queue when starting playback on the last item when not repeating
- final PlayQueue autoQueue = PlayerHelper.autoQueueOf(metadata.getMetadata(),
+ final PlayQueue autoQueue = PlayerHelper.autoQueueOf(info,
playQueue.getStreams());
if (autoQueue != null) {
playQueue.append(autoQueue.getStreams());
@@ -3100,7 +3153,7 @@ public final class Player implements
return;
}
- if (playQueue.getIndex() == index && simpleExoPlayer.getCurrentWindowIndex() == index) {
+ if (playQueue.getIndex() == index && simpleExoPlayer.getCurrentMediaItemIndex() == index) {
seekToDefault();
} else {
saveStreamProgressState();
@@ -3125,6 +3178,7 @@ public final class Player implements
binding.itemsListHeaderDuration.setVisibility(View.VISIBLE);
binding.shuffleButton.setVisibility(View.VISIBLE);
binding.repeatButton.setVisibility(View.VISIBLE);
+ binding.addToPlaylistButton.setVisibility(View.VISIBLE);
hideControls(0, 0);
binding.itemsListPanel.requestFocus();
@@ -3162,6 +3216,7 @@ public final class Player implements
binding.itemsListHeaderDuration.setVisibility(View.GONE);
binding.shuffleButton.setVisibility(View.GONE);
binding.repeatButton.setVisibility(View.GONE);
+ binding.addToPlaylistButton.setVisibility(View.GONE);
hideControls(0, 0);
binding.itemsListPanel.requestFocus();
@@ -3184,12 +3239,11 @@ public final class Player implements
itemTouchHelper.attachToRecyclerView(null);
}
- if (currentMetadata != null) {
- segmentAdapter.setItems(currentMetadata.getMetadata());
- }
+ getCurrentStreamInfo().ifPresent(segmentAdapter::setItems);
binding.shuffleButton.setVisibility(View.GONE);
binding.repeatButton.setVisibility(View.GONE);
+ binding.addToPlaylistButton.setVisibility(View.GONE);
binding.itemsListClose.setOnClickListener(view -> closeItemsList());
}
@@ -3209,6 +3263,9 @@ public final class Player implements
binding.itemsListPanel.setTranslationY(
-binding.itemsListPanel.getHeight() * 5);
});
+
+ // clear focus, otherwise a white rectangle remains on top of the player
+ binding.itemsListClose.clearFocus();
binding.playPauseButton.requestFocus();
}
}
@@ -3236,7 +3293,9 @@ public final class Player implements
private int getNearestStreamSegmentPosition(final long playbackPosition) {
int nearestPosition = 0;
- final List segments = currentMetadata.getMetadata().getStreamSegments();
+ final List segments = getCurrentStreamInfo()
+ .map(StreamInfo::getStreamSegments)
+ .orElse(Collections.emptyList());
for (int i = 0; i < segments.size(); i++) {
if (segments.get(i).getStartTimeSeconds() * 1000L > playbackPosition) {
@@ -3292,7 +3351,27 @@ public final class Player implements
@Override // own playback listener
@Nullable
public MediaSource sourceOf(final PlayQueueItem item, final StreamInfo info) {
- return (isAudioOnly ? audioResolver : videoResolver).resolve(info);
+ if (audioPlayerSelected()) {
+ return audioResolver.resolve(info);
+ }
+
+ if (isAudioOnly && videoResolver.getStreamSourceType().orElse(
+ SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY)
+ == SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY) {
+ // If the current info has only video streams with audio and if the stream is played as
+ // audio, we need to use the audio resolver, otherwise the video stream will be played
+ // in background.
+ return audioResolver.resolve(info);
+ }
+
+ // Even if the stream is played in background, we need to use the video resolver if the
+ // info played is separated video-only and audio-only streams; otherwise, if the audio
+ // resolver was called when the app was in background, the app will only stream audio when
+ // the user come back to the app and will never fetch the video stream.
+ // Note that the video is not fetched when the app is in background because the video
+ // renderer is fully disabled (see useVideoSource method), except for HLS streams
+ // (see https://github.com/google/ExoPlayer/issues/9282).
+ return videoResolver.resolve(info);
}
public void disablePreloadingOfCurrentTrack() {
@@ -3307,10 +3386,10 @@ public final class Player implements
}
private void updateStreamRelatedViews() {
- if (currentMetadata == null) {
+ if (!getCurrentStreamInfo().isPresent()) {
return;
}
- final StreamInfo info = currentMetadata.getMetadata();
+ final StreamInfo info = getCurrentStreamInfo().get();
binding.qualityTextView.setVisibility(View.GONE);
binding.playbackSpeed.setVisibility(View.GONE);
@@ -3338,12 +3417,16 @@ public final class Player implements
break;
case VIDEO_STREAM:
- if (info.getVideoStreams().size() + info.getVideoOnlyStreams().size() == 0) {
+ if (currentMetadata == null
+ || !currentMetadata.getMaybeQuality().isPresent()
+ || (info.getVideoStreams().isEmpty()
+ && info.getVideoOnlyStreams().isEmpty())) {
break;
}
- availableStreams = currentMetadata.getSortedAvailableVideoStreams();
- selectedStreamIndex = currentMetadata.getSelectedVideoStreamIndex();
+ availableStreams = currentMetadata.getMaybeQuality().get().getSortedVideoStreams();
+ selectedStreamIndex =
+ currentMetadata.getMaybeQuality().get().getSelectedVideoStreamIndex();
buildQualityMenu();
binding.qualityTextView.setVisibility(View.VISIBLE);
@@ -3430,17 +3513,7 @@ public final class Player implements
return;
}
captionPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_CAPTION);
-
- final String userPreferredLanguage =
- prefs.getString(context.getString(R.string.caption_user_set_key), null);
- /*
- * only search for autogenerated cc as fallback
- * if "(auto-generated)" was not already selected
- * we are only looking for "(" instead of "(auto-generated)" to hopefully get all
- * internationalized variants such as "(automatisch-erzeugt)" and so on
- */
- boolean searchForAutogenerated = userPreferredLanguage != null
- && !userPreferredLanguage.contains("(");
+ captionPopupMenu.setOnDismissListener(this);
// Add option for turning off caption
final MenuItem captionOffItem = captionPopupMenu.getMenu().add(POPUP_MENU_ID_CAPTION,
@@ -3463,30 +3536,54 @@ public final class Player implements
captionItem.setOnMenuItemClickListener(menuItem -> {
final int textRendererIndex = getCaptionRendererIndex();
if (textRendererIndex != RENDERER_UNAVAILABLE) {
- trackSelector.setPreferredTextLanguage(captionLanguage);
+ // DefaultTrackSelector will select for text tracks in the following order.
+ // When multiple tracks share the same rank, a random track will be chosen.
+ // 1. ANY track exactly matching preferred language name
+ // 2. ANY track exactly matching preferred language stem
+ // 3. ROLE_FLAG_CAPTION track matching preferred language stem
+ // 4. ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND track matching preferred language stem
+ // This means if a caption track of preferred language is not available,
+ // then an auto-generated track of that language will be chosen automatically.
trackSelector.setParameters(trackSelector.buildUponParameters()
+ .setPreferredTextLanguages(captionLanguage,
+ PlayerHelper.captionLanguageStemOf(captionLanguage))
+ .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION)
.setRendererDisabled(textRendererIndex, false));
prefs.edit().putString(context.getString(R.string.caption_user_set_key),
captionLanguage).apply();
}
return true;
});
- // apply caption language from previous user preference
- if (userPreferredLanguage != null
- && (captionLanguage.equals(userPreferredLanguage)
- || (searchForAutogenerated && captionLanguage.startsWith(userPreferredLanguage))
- || (userPreferredLanguage.contains("(") && captionLanguage.startsWith(
- userPreferredLanguage.substring(0, userPreferredLanguage.indexOf('(')))))) {
- final int textRendererIndex = getCaptionRendererIndex();
- if (textRendererIndex != RENDERER_UNAVAILABLE) {
- trackSelector.setPreferredTextLanguage(captionLanguage);
- trackSelector.setParameters(trackSelector.buildUponParameters()
- .setRendererDisabled(textRendererIndex, false));
- }
- searchForAutogenerated = false;
- }
}
- captionPopupMenu.setOnDismissListener(this);
+
+ // apply caption language from previous user preference
+ final int textRendererIndex = getCaptionRendererIndex();
+ if (textRendererIndex == RENDERER_UNAVAILABLE) {
+ return;
+ }
+
+ // If user prefers to show no caption, then disable the renderer.
+ // Otherwise, DefaultTrackSelector may automatically find an available caption
+ // and display that.
+ final String userPreferredLanguage =
+ prefs.getString(context.getString(R.string.caption_user_set_key), null);
+ if (userPreferredLanguage == null) {
+ trackSelector.setParameters(trackSelector.buildUponParameters()
+ .setRendererDisabled(textRendererIndex, true));
+ return;
+ }
+
+ // Only set preferred language if it does not match the user preference,
+ // otherwise there might be an infinite cycle at onTextTracksChanged.
+ final List selectedPreferredLanguages =
+ trackSelector.getParameters().preferredTextLanguages;
+ if (!selectedPreferredLanguages.contains(userPreferredLanguage)) {
+ trackSelector.setParameters(trackSelector.buildUponParameters()
+ .setPreferredTextLanguages(userPreferredLanguage,
+ PlayerHelper.captionLanguageStemOf(userPreferredLanguage))
+ .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION)
+ .setRendererDisabled(textRendererIndex, false));
+ }
}
/**
@@ -3544,37 +3641,6 @@ public final class Player implements
}
}
- private void onQualitySelectorClicked() {
- if (DEBUG) {
- Log.d(TAG, "onQualitySelectorClicked() called");
- }
- qualityPopupMenu.show();
- isSomePopupMenuVisible = true;
-
- final VideoStream videoStream = getSelectedVideoStream();
- if (videoStream != null) {
- final String qualityText = MediaFormat.getNameById(videoStream.getFormatId()) + " "
- + videoStream.resolution;
- binding.qualityTextView.setText(qualityText);
- }
-
- saveWasPlaying();
- }
-
- private void onPlaybackSpeedClicked() {
- if (DEBUG) {
- Log.d(TAG, "onPlaybackSpeedClicked() called");
- }
- if (videoPlayerSelected()) {
- PlaybackParameterDialog.newInstance(getPlaybackSpeed(), getPlaybackPitch(),
- getPlaybackSkipSilence(), this::setPlaybackParameters)
- .show(getParentActivity().getSupportFragmentManager(), null);
- } else {
- playbackSpeedPopupMenu.show();
- isSomePopupMenuVisible = true;
- }
- }
-
private void onCaptionClicked() {
if (DEBUG) {
Log.d(TAG, "onCaptionClicked() called");
@@ -3613,40 +3679,43 @@ public final class Player implements
binding.subtitleView.setStyle(captionStyle);
}
- private void onTextTracksChanged() {
- final int textRenderer = getCaptionRendererIndex();
-
+ private void onTextTracksChanged(@NonNull final TracksInfo currentTrackInfo) {
if (binding == null) {
return;
}
+
if (trackSelector.getCurrentMappedTrackInfo() == null
- || textRenderer == RENDERER_UNAVAILABLE) {
+ || !currentTrackInfo.isTypeSupportedOrEmpty(C.TRACK_TYPE_TEXT)) {
binding.captionTextView.setVisibility(View.GONE);
return;
}
- final TrackGroupArray textTracks = trackSelector.getCurrentMappedTrackInfo()
- .getTrackGroups(textRenderer);
-
// Extract all loaded languages
- final List availableLanguages = new ArrayList<>(textTracks.length);
- for (int i = 0; i < textTracks.length; i++) {
- final TrackGroup textTrack = textTracks.get(i);
- if (textTrack.length > 0) {
- availableLanguages.add(textTrack.getFormat(0).language);
- }
- }
+ final List textTracks = currentTrackInfo
+ .getTrackGroupInfos()
+ .stream()
+ .filter(trackGroupInfo -> C.TRACK_TYPE_TEXT == trackGroupInfo.getTrackType())
+ .collect(Collectors.toList());
+ final List availableLanguages = textTracks.stream()
+ .map(TracksInfo.TrackGroupInfo::getTrackGroup)
+ .filter(textTrack -> textTrack.length > 0)
+ .map(textTrack -> textTrack.getFormat(0).language)
+ .collect(Collectors.toList());
+
+ // Find selected text track
+ final Optional selectedTracks = textTracks.stream()
+ .filter(TracksInfo.TrackGroupInfo::isSelected)
+ .filter(info -> info.getTrackGroup().length >= 1)
+ .map(info -> info.getTrackGroup().getFormat(0))
+ .findFirst();
- // Normalize mismatching language strings
- final String preferredLanguage = trackSelector.getPreferredTextLanguage();
// Build UI
buildCaptionMenu(availableLanguages);
- if (trackSelector.getParameters().getRendererDisabled(textRenderer)
- || preferredLanguage == null || (!availableLanguages.contains(preferredLanguage)
- && !containsCaseInsensitive(availableLanguages, preferredLanguage))) {
+ if (trackSelector.getParameters().getRendererDisabled(getCaptionRendererIndex())
+ || !selectedTracks.isPresent()) {
binding.captionTextView.setText(R.string.caption_none);
} else {
- binding.captionTextView.setText(preferredLanguage);
+ binding.captionTextView.setText(selectedTracks.get().language);
}
binding.captionTextView.setVisibility(
availableLanguages.isEmpty() ? View.GONE : View.VISIBLE);
@@ -3679,11 +3748,7 @@ public final class Player implements
if (DEBUG) {
Log.d(TAG, "onClick() called with: v = [" + v + "]");
}
- if (v.getId() == binding.qualityTextView.getId()) {
- onQualitySelectorClicked();
- } else if (v.getId() == binding.playbackSpeed.getId()) {
- onPlaybackSpeedClicked();
- } else if (v.getId() == binding.resizeTextView.getId()) {
+ if (v.getId() == binding.resizeTextView.getId()) {
onResizeClicked();
} else if (v.getId() == binding.captionTextView.getId()) {
onCaptionClicked();
@@ -3695,18 +3760,6 @@ public final class Player implements
playPrevious();
} else if (v.getId() == binding.playNextButton.getId()) {
playNext();
- } else if (v.getId() == binding.queueButton.getId()) {
- onQueueClicked();
- return;
- } else if (v.getId() == binding.segmentsButton.getId()) {
- onSegmentsClicked();
- return;
- } else if (v.getId() == binding.repeatButton.getId()) {
- onRepeatClicked();
- return;
- } else if (v.getId() == binding.shuffleButton.getId()) {
- onShuffleClicked();
- return;
} else if (v.getId() == binding.moreOptionsButton.getId()) {
onMoreOptionsClicked();
} else if (v.getId() == binding.share.getId()) {
@@ -3735,23 +3788,33 @@ public final class Player implements
context.sendBroadcast(new Intent(VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER));
}
- if (currentState != STATE_COMPLETED) {
- controlsVisibilityHandler.removeCallbacksAndMessages(null);
- showHideShadow(true, DEFAULT_CONTROLS_DURATION);
- animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION,
- AnimationType.ALPHA, 0, () -> {
- if (currentState == STATE_PLAYING && !isSomePopupMenuVisible) {
- if (v.getId() == binding.playPauseButton.getId()
- // Hide controls in fullscreen immediately
- || (v.getId() == binding.screenRotationButton.getId()
- && isFullscreen)) {
- hideControls(0, 0);
- } else {
- hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME);
- }
- }
- });
+ manageControlsAfterOnClick(v);
+ }
+
+ /**
+ * Manages the controls after a click occurred on the player UI.
+ * @param v – The view that was clicked
+ */
+ public void manageControlsAfterOnClick(@NonNull final View v) {
+ if (currentState == STATE_COMPLETED) {
+ return;
}
+
+ controlsVisibilityHandler.removeCallbacksAndMessages(null);
+ showHideShadow(true, DEFAULT_CONTROLS_DURATION);
+ animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION,
+ AnimationType.ALPHA, 0, () -> {
+ if (currentState == STATE_PLAYING && !isSomePopupMenuVisible) {
+ if (v.getId() == binding.playPauseButton.getId()
+ // Hide controls in fullscreen immediately
+ || (v.getId() == binding.screenRotationButton.getId()
+ && isFullscreen)) {
+ hideControls(0, 0);
+ } else {
+ hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME);
+ }
+ }
+ });
}
@Override
@@ -3773,6 +3836,10 @@ public final class Player implements
case KeyEvent.KEYCODE_SPACE:
if (isFullscreen) {
playPause();
+ if (isPlaying()) {
+ hideControls(0, 0);
+ }
+ return true;
}
break;
case KeyEvent.KEYCODE_BACK:
@@ -3786,8 +3853,9 @@ public final class Player implements
case KeyEvent.KEYCODE_DPAD_DOWN:
case KeyEvent.KEYCODE_DPAD_RIGHT:
case KeyEvent.KEYCODE_DPAD_CENTER:
- if (binding.getRoot().hasFocus() && !binding.playbackControlRoot.hasFocus()) {
- // do not interfere with focus in playlist etc.
+ if ((binding.getRoot().hasFocus() && !binding.playbackControlRoot.hasFocus())
+ || isQueueVisible) {
+ // do not interfere with focus in playlist and play queue etc.
return false;
}
@@ -3795,15 +3863,13 @@ public final class Player implements
return true;
}
- if (!isControlsVisible()) {
- if (!isQueueVisible) {
- binding.playPauseButton.requestFocus();
- }
+ if (isControlsVisible()) {
+ hideControls(DEFAULT_CONTROLS_DURATION, DPAD_CONTROLS_HIDE_TIME);
+ } else {
+ binding.playPauseButton.requestFocus();
showControlsThenHide();
showSystemUIPartially();
return true;
- } else {
- hideControls(DEFAULT_CONTROLS_DURATION, DPAD_CONTROLS_HIDE_TIME);
}
break;
}
@@ -3848,10 +3914,10 @@ public final class Player implements
}
private void onOpenInBrowserClicked() {
- if (currentMetadata != null) {
- ShareUtils.openUrlInBrowser(getParentActivity(),
- currentMetadata.getMetadata().getOriginalUrl());
- }
+ getCurrentStreamInfo()
+ .map(Info::getOriginalUrl)
+ .ifPresent(originalUrl -> ShareUtils.openUrlInBrowser(
+ Objects.requireNonNull(getParentActivity()), originalUrl));
}
//endregion
@@ -4107,12 +4173,14 @@ public final class Player implements
}
private void notifyMetadataUpdateToListeners() {
- if (fragmentListener != null && currentMetadata != null) {
- fragmentListener.onMetadataUpdate(currentMetadata.getMetadata(), playQueue);
- }
- if (activityListener != null && currentMetadata != null) {
- activityListener.onMetadataUpdate(currentMetadata.getMetadata(), playQueue);
- }
+ getCurrentStreamInfo().ifPresent(info -> {
+ if (fragmentListener != null) {
+ fragmentListener.onMetadataUpdate(info, playQueue);
+ }
+ if (activityListener != null) {
+ activityListener.onMetadataUpdate(info, playQueue);
+ }
+ });
}
private void notifyPlaybackUpdateToListeners() {
@@ -4147,19 +4215,128 @@ public final class Player implements
return (AppCompatActivity) ((ViewGroup) binding.getRoot().getParent()).getContext();
}
- private void useVideoSource(final boolean video) {
- if (playQueue == null || isAudioOnly == !video || audioPlayerSelected()) {
+ private void useVideoSource(final boolean videoEnabled) {
+ if (playQueue == null || isAudioOnly == !videoEnabled || audioPlayerSelected()) {
return;
}
- isAudioOnly = !video;
- // When a user returns from background controls could be hidden
- // but systemUI will be shown 100%. Hide it
+ isAudioOnly = !videoEnabled;
+ // When a user returns from background, controls could be hidden but SystemUI will be shown
+ // 100%. Hide it.
if (!isAudioOnly && !isControlsVisible()) {
hideSystemUIIfNeeded();
}
+
+ // The current metadata may be null sometimes (for e.g. when using an unstable connection
+ // in livestreams) so we will be not able to execute the block below.
+ // Reload the play queue manager in this case, which is the behavior when we don't know the
+ // index of the video renderer or playQueueManagerReloadingNeeded returns true.
+ final Optional optCurrentStreamInfo = getCurrentStreamInfo();
+ if (!optCurrentStreamInfo.isPresent()) {
+ reloadPlayQueueManager();
+ setRecovery();
+ return;
+ }
+
+ final StreamInfo info = optCurrentStreamInfo.get();
+
+ // In the case we don't know the source type, fallback to the one with video with audio or
+ // audio-only source.
+ final SourceType sourceType = videoResolver.getStreamSourceType().orElse(
+ SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY);
+
+ if (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) {
+ reloadPlayQueueManager();
+ } else {
+ final StreamType streamType = info.getStreamType();
+ if (streamType == StreamType.AUDIO_STREAM
+ || streamType == StreamType.AUDIO_LIVE_STREAM) {
+ // Nothing to do more than setting the recovery position
+ setRecovery();
+ return;
+ }
+
+ final DefaultTrackSelector.ParametersBuilder parametersBuilder =
+ trackSelector.buildUponParameters();
+
+ if (videoEnabled) {
+ // Enable again the video track and the subtitles, if there is one selected
+ parametersBuilder.setDisabledTrackTypes(Collections.emptySet());
+ } else {
+ // Disable the video track and the ability to select subtitles
+ // Use an ArraySet because we can't use Set.of() on all supported APIs by the app
+ final ArraySet disabledTracks = new ArraySet<>();
+ disabledTracks.add(C.TRACK_TYPE_TEXT);
+ disabledTracks.add(C.TRACK_TYPE_VIDEO);
+ parametersBuilder.setDisabledTrackTypes(disabledTracks);
+ }
+
+ trackSelector.setParameters(parametersBuilder);
+ }
+
setRecovery();
- reloadPlayQueueManager();
+ }
+
+ /**
+ * Return whether the play queue manager needs to be reloaded when switching player type.
+ *
+ *
+ * The play queue manager needs to be reloaded if the video renderer index is not known and if
+ * the content is not an audio content, but also if none of the following cases is met:
+ *
+ *
+ *
the content is an {@link StreamType#AUDIO_STREAM audio stream} or an
+ * {@link StreamType#AUDIO_LIVE_STREAM audio live stream};
+ *
the content is a {@link StreamType#LIVE_STREAM live stream} and the source type is a
+ * {@link SourceType#LIVE_STREAM live source};
+ *
the content's source is {@link SourceType#VIDEO_WITH_SEPARATED_AUDIO a video stream
+ * with a separated audio source} or has no audio-only streams available and is a
+ * {@link StreamType#LIVE_STREAM live stream} or a
+ * {@link StreamType#LIVE_STREAM live stream}.
+ *
+ *
+ *
+ *
+ * @param sourceType the {@link SourceType} of the stream
+ * @param streamInfo the {@link StreamInfo} of the stream
+ * @param videoRendererIndex the video renderer index of the video source, if that's a video
+ * source (or {@link #RENDERER_UNAVAILABLE})
+ * @return whether the play queue manager needs to be reloaded
+ */
+ private boolean playQueueManagerReloadingNeeded(final SourceType sourceType,
+ @NonNull final StreamInfo streamInfo,
+ final int videoRendererIndex) {
+ final StreamType streamType = streamInfo.getStreamType();
+
+ if (videoRendererIndex == RENDERER_UNAVAILABLE && streamType != StreamType.AUDIO_STREAM
+ && streamType != StreamType.AUDIO_LIVE_STREAM) {
+ return true;
+ }
+
+ // The content is an audio stream, an audio live stream, or a live stream with a live
+ // source: it's not needed to reload the play queue manager because the stream source will
+ // be the same
+ if ((streamType == StreamType.AUDIO_STREAM || streamType == StreamType.AUDIO_LIVE_STREAM)
+ || (streamType == StreamType.LIVE_STREAM
+ && sourceType == SourceType.LIVE_STREAM)) {
+ return false;
+ }
+
+ // The content's source is a video with separated audio or a video with audio -> the video
+ // and its fetch may be disabled
+ // The content's source is a video with embedded audio and the content has no separated
+ // audio stream available: it's probably not needed to reload the play queue manager
+ // because the stream source will be probably the same as the current played
+ if (sourceType == SourceType.VIDEO_WITH_SEPARATED_AUDIO
+ || (sourceType == SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY
+ && isNullOrEmpty(streamInfo.getAudioStreams()))) {
+ // It's not needed to reload the play queue manager only if the content's stream type
+ // is a video stream or a live stream
+ return streamType != StreamType.VIDEO_STREAM && streamType != StreamType.LIVE_STREAM;
+ }
+
+ // Other cases: the play queue manager reload is needed
+ return true;
}
//endregion
@@ -4169,6 +4346,10 @@ public final class Player implements
//////////////////////////////////////////////////////////////////////////*/
//region Getters
+ private Optional getCurrentStreamInfo() {
+ return Optional.ofNullable(currentMetadata).flatMap(MediaItemTag::getMaybeStreamInfo);
+ }
+
public int getCurrentState() {
return currentState;
}
@@ -4178,8 +4359,7 @@ public final class Player implements
}
public boolean isStopped() {
- return exoPlayerIsNull()
- || simpleExoPlayer.getPlaybackState() == SimpleExoPlayer.STATE_IDLE;
+ return exoPlayerIsNull() || simpleExoPlayer.getPlaybackState() == ExoPlayer.STATE_IDLE;
}
public boolean isPlaying() {
@@ -4196,8 +4376,8 @@ public final class Player implements
private boolean isLive() {
try {
- return !exoPlayerIsNull() && simpleExoPlayer.isCurrentWindowDynamic();
- } catch (@NonNull final IndexOutOfBoundsException e) {
+ return !exoPlayerIsNull() && simpleExoPlayer.isCurrentMediaItemDynamic();
+ } catch (final IndexOutOfBoundsException e) {
// Why would this even happen =(... but lets log it anyway, better safe than sorry
if (DEBUG) {
Log.d(TAG, "player.isCurrentWindowDynamic() failed: ", e);
@@ -4269,6 +4449,10 @@ public final class Player implements
return isSomePopupMenuVisible;
}
+ public void setSomePopupMenuVisible(final boolean somePopupMenuVisible) {
+ isSomePopupMenuVisible = somePopupMenuVisible;
+ }
+
public ImageButton getPlayPauseButton() {
return binding.playPauseButton;
}
@@ -4350,6 +4534,11 @@ public final class Player implements
public PlayQueueAdapter getPlayQueueAdapter() {
return playQueueAdapter;
}
+
+ public PlayerBinding getBinding() {
+ return binding;
+ }
+
//endregion
@@ -4366,24 +4555,55 @@ public final class Player implements
surfaceHolderCallback = new SurfaceHolderCallback(context, simpleExoPlayer);
binding.surfaceView.getHolder().addCallback(surfaceHolderCallback);
final Surface surface = binding.surfaceView.getHolder().getSurface();
- // initially set the surface manually otherwise
- // onRenderedFirstFrame() will not be called
- simpleExoPlayer.setVideoSurface(surface);
+ // ensure player is using an unreleased surface, which the surfaceView might not be
+ // when starting playback on background or during player switching
+ if (surface.isValid()) {
+ // initially set the surface manually otherwise
+ // onRenderedFirstFrame() will not be called
+ simpleExoPlayer.setVideoSurface(surface);
+ }
} else {
simpleExoPlayer.setVideoSurfaceView(binding.surfaceView);
}
}
private void cleanupVideoSurface() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // >=API23
- if (surfaceHolderCallback != null) {
- if (binding != null) {
- binding.surfaceView.getHolder().removeCallback(surfaceHolderCallback);
- }
- surfaceHolderCallback.release();
- surfaceHolderCallback = null;
+ // Only for API >= 23
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && surfaceHolderCallback != null) {
+ if (binding != null) {
+ binding.surfaceView.getHolder().removeCallback(surfaceHolderCallback);
}
+ surfaceHolderCallback.release();
+ surfaceHolderCallback = null;
}
}
//endregion
+
+ /**
+ * Get the video renderer index of the current playing stream.
+ *
+ * This method returns the video renderer index of the current
+ * {@link MappingTrackSelector.MappedTrackInfo} or {@link #RENDERER_UNAVAILABLE} if the current
+ * {@link MappingTrackSelector.MappedTrackInfo} is null or if there is no video renderer index.
+ *
+ * @return the video renderer index or {@link #RENDERER_UNAVAILABLE} if it cannot be get
+ */
+ private int getVideoRendererIndex() {
+ final MappingTrackSelector.MappedTrackInfo mappedTrackInfo = trackSelector
+ .getCurrentMappedTrackInfo();
+
+ if (mappedTrackInfo == null) {
+ return RENDERER_UNAVAILABLE;
+ }
+
+ // Check every renderer
+ return IntStream.range(0, mappedTrackInfo.getRendererCount())
+ // Check the renderer is a video renderer and has at least one track
+ .filter(i -> !mappedTrackInfo.getTrackGroups(i).isEmpty()
+ && simpleExoPlayer.getRendererType(i) == C.TRACK_TYPE_VIDEO)
+ // Return the first index found (there is at most one renderer per renderer type)
+ .findFirst()
+ // No video renderer index with at least one track found: return unavailable index
+ .orElse(RENDERER_UNAVAILABLE);
+ }
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java
index f8d03087e..359eab8b2 100644
--- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java
+++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java
@@ -1,6 +1,6 @@
package org.schabi.newpipe.player.event;
-import com.google.android.exoplayer2.ExoPlaybackException;
+import com.google.android.exoplayer2.PlaybackException;
public interface PlayerServiceEventListener extends PlayerEventListener {
void onFullscreenStateChanged(boolean fullscreen);
@@ -9,7 +9,7 @@ public interface PlayerServiceEventListener extends PlayerEventListener {
void onMoreOptionsLongClicked();
- void onPlayerError(ExoPlaybackException error);
+ void onPlayerError(PlaybackException error, boolean isCatchableException);
void hideSystemUiIfNeeded();
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java
index b36f9f234..a05990816 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java
@@ -14,7 +14,7 @@ import androidx.core.content.ContextCompat;
import androidx.media.AudioFocusRequestCompat;
import androidx.media.AudioManagerCompat;
-import com.google.android.exoplayer2.SimpleExoPlayer;
+import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.analytics.AnalyticsListener;
public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, AnalyticsListener {
@@ -27,14 +27,14 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, An
private static final int FOCUS_GAIN_TYPE = AudioManagerCompat.AUDIOFOCUS_GAIN;
private static final int STREAM_TYPE = AudioManager.STREAM_MUSIC;
- private final SimpleExoPlayer player;
+ private final ExoPlayer player;
private final Context context;
private final AudioManager audioManager;
private final AudioFocusRequestCompat request;
public AudioReactor(@NonNull final Context context,
- @NonNull final SimpleExoPlayer player) {
+ @NonNull final ExoPlayer player) {
this.player = player;
this.context = context;
this.audioManager = ContextCompat.getSystemService(context, AudioManager.class);
@@ -149,7 +149,8 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, An
//////////////////////////////////////////////////////////////////////////*/
@Override
- public void onAudioSessionIdChanged(final EventTime eventTime, final int audioSessionId) {
+ public void onAudioSessionIdChanged(@NonNull final EventTime eventTime,
+ final int audioSessionId) {
notifyAudioSessionUpdate(true, audioSessionId);
}
private void notifyAudioSessionUpdate(final boolean active, final int audioSessionId) {
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java
index 9703a3588..98e04d466 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java
@@ -3,12 +3,10 @@ package org.schabi.newpipe.player.helper;
import android.content.Context;
import android.util.Log;
-import androidx.annotation.NonNull;
-
-import com.google.android.exoplayer2.database.ExoDatabaseProvider;
+import com.google.android.exoplayer2.database.StandaloneDatabaseProvider;
import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DefaultDataSource;
-import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
+import com.google.android.exoplayer2.upstream.DefaultHttpDataSource;
import com.google.android.exoplayer2.upstream.FileDataSource;
import com.google.android.exoplayer2.upstream.TransferListener;
import com.google.android.exoplayer2.upstream.cache.CacheDataSink;
@@ -18,6 +16,8 @@ import com.google.android.exoplayer2.upstream.cache.SimpleCache;
import java.io.File;
+import androidx.annotation.NonNull;
+
/* package-private */ class CacheFactory implements DataSource.Factory {
private static final String TAG = "CacheFactory";
@@ -25,7 +25,7 @@ import java.io.File;
private static final int CACHE_FLAGS = CacheDataSource.FLAG_BLOCK_ON_CACHE
| CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR;
- private final DefaultDataSourceFactory dataSourceFactory;
+ private final DataSource.Factory dataSourceFactory;
private final File cacheDir;
private final long maxFileSize;
@@ -49,7 +49,9 @@ import java.io.File;
final long maxFileSize) {
this.maxFileSize = maxFileSize;
- dataSourceFactory = new DefaultDataSourceFactory(context, userAgent, transferListener);
+ dataSourceFactory = new DefaultDataSource
+ .Factory(context, new DefaultHttpDataSource.Factory().setUserAgent(userAgent))
+ .setTransferListener(transferListener);
cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME);
if (!cacheDir.exists()) {
//noinspection ResultOfMethodCallIgnored
@@ -59,15 +61,16 @@ import java.io.File;
if (cache == null) {
final LeastRecentlyUsedCacheEvictor evictor
= new LeastRecentlyUsedCacheEvictor(maxCacheSize);
- cache = new SimpleCache(cacheDir, evictor, new ExoDatabaseProvider(context));
+ cache = new SimpleCache(cacheDir, evictor, new StandaloneDatabaseProvider(context));
}
}
+ @NonNull
@Override
public DataSource createDataSource() {
Log.d(TAG, "initExoPlayerCache: cacheDir = " + cacheDir.getAbsolutePath());
- final DefaultDataSource dataSource = dataSourceFactory.createDataSource();
+ final DataSource dataSource = dataSourceFactory.createDataSource();
final FileDataSource fileSource = new FileDataSource();
final CacheDataSink dataSink = new CacheDataSink(cache, maxFileSize);
@@ -86,8 +89,8 @@ import java.io.File;
Log.d(TAG, "tryDeleteCacheFiles: " + filePath + " deleted = " + deleteSuccessful);
}
- } catch (final Exception ignored) {
- Log.e(TAG, "Failed to delete file.", ignored);
+ } catch (final Exception e) {
+ Log.e(TAG, "Failed to delete file.", e);
}
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java b/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java
index 8d344c877..c12ba754a 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java
@@ -19,7 +19,6 @@ import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.player.mediasession.MediaSessionCallback;
import org.schabi.newpipe.player.mediasession.PlayQueueNavigator;
-import org.schabi.newpipe.player.mediasession.PlayQueuePlaybackController;
import java.util.Optional;
@@ -55,7 +54,6 @@ public class MediaSessionManager {
.build());
sessionConnector = new MediaSessionConnector(mediaSession);
- sessionConnector.setControlDispatcher(new PlayQueuePlaybackController(callback));
sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, callback));
sessionConnector.setPlayer(player);
}
@@ -135,9 +133,7 @@ public class MediaSessionManager {
lastTitleHashCode = title.hashCode();
lastArtistHashCode = artist.hashCode();
lastDuration = duration;
- if (optAlbumArt.isPresent()) {
- lastAlbumArtHashCode = optAlbumArt.get().hashCode();
- }
+ optAlbumArt.ifPresent(bitmap -> lastAlbumArtHashCode = bitmap.hashCode());
}
private boolean checkIfMetadataShouldBeSet(
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java
index 5139ef9cd..1a55c21c3 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java
@@ -9,6 +9,7 @@ import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.CheckBox;
+import android.widget.RelativeLayout;
import android.widget.SeekBar;
import android.widget.TextView;
@@ -19,6 +20,7 @@ import androidx.fragment.app.DialogFragment;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R;
+import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
import org.schabi.newpipe.util.SliderStrategy;
public class PlaybackParameterDialog extends DialogFragment {
@@ -37,6 +39,7 @@ public class PlaybackParameterDialog extends DialogFragment {
private static final double DEFAULT_TEMPO = 1.00f;
private static final double DEFAULT_PITCH = 1.00f;
+ private static final int DEFAULT_SEMITONES = 0;
private static final double DEFAULT_STEP = STEP_TWENTY_FIVE_PERCENT_VALUE;
private static final boolean DEFAULT_SKIP_SILENCE = false;
@@ -64,10 +67,11 @@ public class PlaybackParameterDialog extends DialogFragment {
private double initialTempo = DEFAULT_TEMPO;
private double initialPitch = DEFAULT_PITCH;
+ private int initialSemitones = DEFAULT_SEMITONES;
private boolean initialSkipSilence = DEFAULT_SKIP_SILENCE;
private double tempo = DEFAULT_TEMPO;
private double pitch = DEFAULT_PITCH;
- private double stepSize = DEFAULT_STEP;
+ private int semitones = DEFAULT_SEMITONES;
@Nullable
private SeekBar tempoSlider;
@@ -86,9 +90,19 @@ public class PlaybackParameterDialog extends DialogFragment {
@Nullable
private TextView pitchStepUpText;
@Nullable
+ private SeekBar semitoneSlider;
+ @Nullable
+ private TextView semitoneCurrentText;
+ @Nullable
+ private TextView semitoneStepDownText;
+ @Nullable
+ private TextView semitoneStepUpText;
+ @Nullable
private CheckBox unhookingCheckbox;
@Nullable
private CheckBox skipSilenceCheckbox;
+ @Nullable
+ private CheckBox adjustBySemitonesCheckbox;
public static PlaybackParameterDialog newInstance(final double playbackTempo,
final double playbackPitch,
@@ -101,6 +115,7 @@ public class PlaybackParameterDialog extends DialogFragment {
dialog.tempo = playbackTempo;
dialog.pitch = playbackPitch;
+ dialog.semitones = dialog.percentToSemitones(playbackPitch);
dialog.initialSkipSilence = playbackSkipSilence;
return dialog;
@@ -111,7 +126,7 @@ public class PlaybackParameterDialog extends DialogFragment {
//////////////////////////////////////////////////////////////////////////*/
@Override
- public void onAttach(final Context context) {
+ public void onAttach(@NonNull final Context context) {
super.onAttach(context);
if (context instanceof Callback) {
callback = (Callback) context;
@@ -127,22 +142,22 @@ public class PlaybackParameterDialog extends DialogFragment {
if (savedInstanceState != null) {
initialTempo = savedInstanceState.getDouble(INITIAL_TEMPO_KEY, DEFAULT_TEMPO);
initialPitch = savedInstanceState.getDouble(INITIAL_PITCH_KEY, DEFAULT_PITCH);
+ initialSemitones = percentToSemitones(initialPitch);
tempo = savedInstanceState.getDouble(TEMPO_KEY, DEFAULT_TEMPO);
pitch = savedInstanceState.getDouble(PITCH_KEY, DEFAULT_PITCH);
- stepSize = savedInstanceState.getDouble(STEP_SIZE_KEY, DEFAULT_STEP);
+ semitones = percentToSemitones(pitch);
}
}
@Override
- public void onSaveInstanceState(final Bundle outState) {
+ public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState);
outState.putDouble(INITIAL_TEMPO_KEY, initialTempo);
outState.putDouble(INITIAL_PITCH_KEY, initialPitch);
outState.putDouble(TEMPO_KEY, getCurrentTempo());
outState.putDouble(PITCH_KEY, getCurrentPitch());
- outState.putDouble(STEP_SIZE_KEY, getCurrentStepSize());
}
/*//////////////////////////////////////////////////////////////////////////
@@ -160,9 +175,11 @@ public class PlaybackParameterDialog extends DialogFragment {
.setView(view)
.setCancelable(true)
.setNegativeButton(R.string.cancel, (dialogInterface, i) ->
- setPlaybackParameters(initialTempo, initialPitch, initialSkipSilence))
+ setPlaybackParameters(initialTempo, initialPitch,
+ initialSemitones, initialSkipSilence))
.setNeutralButton(R.string.playback_reset, (dialogInterface, i) ->
- setPlaybackParameters(DEFAULT_TEMPO, DEFAULT_PITCH, DEFAULT_SKIP_SILENCE))
+ setPlaybackParameters(DEFAULT_TEMPO, DEFAULT_PITCH,
+ DEFAULT_SEMITONES, DEFAULT_SKIP_SILENCE))
.setPositiveButton(R.string.ok, (dialogInterface, i) ->
setCurrentPlaybackParameters());
@@ -176,14 +193,49 @@ public class PlaybackParameterDialog extends DialogFragment {
private void setupControlViews(@NonNull final View rootView) {
setupHookingControl(rootView);
setupSkipSilenceControl(rootView);
+ setupAdjustBySemitonesControl(rootView);
setupTempoControl(rootView);
setupPitchControl(rootView);
+ setupSemitoneControl(rootView);
+
+ togglePitchSliderType(rootView);
- setStepSize(stepSize);
setupStepSizeSelector(rootView);
}
+ private void togglePitchSliderType(@NonNull final View rootView) {
+ final RelativeLayout pitchControl = rootView.findViewById(R.id.pitchControl);
+ final RelativeLayout semitoneControl = rootView.findViewById(R.id.semitoneControl);
+
+ final View separatorStepSizeSelector =
+ rootView.findViewById(R.id.separatorStepSizeSelector);
+ final RelativeLayout.LayoutParams params =
+ (RelativeLayout.LayoutParams) separatorStepSizeSelector.getLayoutParams();
+ if (pitchControl != null && semitoneControl != null && unhookingCheckbox != null) {
+ if (getCurrentAdjustBySemitones()) {
+ // replaces pitchControl slider with semitoneControl slider
+ pitchControl.setVisibility(View.GONE);
+ semitoneControl.setVisibility(View.VISIBLE);
+ params.addRule(RelativeLayout.BELOW, R.id.semitoneControl);
+
+ // forces unhook for semitones
+ unhookingCheckbox.setChecked(true);
+ unhookingCheckbox.setEnabled(false);
+
+ setupTempoStepSizeSelector(rootView);
+ } else {
+ semitoneControl.setVisibility(View.GONE);
+ pitchControl.setVisibility(View.VISIBLE);
+ params.addRule(RelativeLayout.BELOW, R.id.pitchControl);
+
+ // (re)enables hooking selection
+ unhookingCheckbox.setEnabled(true);
+ setupCombinedStepSizeSelector(rootView);
+ }
+ }
+ }
+
private void setupTempoControl(@NonNull final View rootView) {
tempoSlider = rootView.findViewById(R.id.tempoSeekbar);
final TextView tempoMinimumText = rootView.findViewById(R.id.tempoMinimumText);
@@ -234,23 +286,40 @@ public class PlaybackParameterDialog extends DialogFragment {
}
}
+ private void setupSemitoneControl(@NonNull final View rootView) {
+ semitoneSlider = rootView.findViewById(R.id.semitoneSeekbar);
+ semitoneCurrentText = rootView.findViewById(R.id.semitoneCurrentText);
+ semitoneStepDownText = rootView.findViewById(R.id.semitoneStepDown);
+ semitoneStepUpText = rootView.findViewById(R.id.semitoneStepUp);
+
+ if (semitoneCurrentText != null) {
+ semitoneCurrentText.setText(getSignedSemitonesString(semitones));
+ }
+
+ if (semitoneSlider != null) {
+ setSemitoneSlider(semitones);
+ semitoneSlider.setOnSeekBarChangeListener(getOnSemitoneChangedListener());
+ }
+
+ }
+
private void setupHookingControl(@NonNull final View rootView) {
unhookingCheckbox = rootView.findViewById(R.id.unhookCheckbox);
if (unhookingCheckbox != null) {
- // restore whether pitch and tempo are unhooked or not
+ // restores whether pitch and tempo are unhooked or not
unhookingCheckbox.setChecked(PreferenceManager
.getDefaultSharedPreferences(requireContext())
.getBoolean(getString(R.string.playback_unhook_key), true));
unhookingCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> {
- // save whether pitch and tempo are unhooked or not
+ // saves whether pitch and tempo are unhooked or not
PreferenceManager.getDefaultSharedPreferences(requireContext())
.edit()
.putBoolean(getString(R.string.playback_unhook_key), isChecked)
.apply();
if (!isChecked) {
- // when unchecked, slide back to the minimum of current tempo or pitch
+ // when unchecked, slides back to the minimum of current tempo or pitch
final double minimum = Math.min(getCurrentPitch(), getCurrentTempo());
setSliders(minimum);
setCurrentPlaybackParameters();
@@ -268,7 +337,51 @@ public class PlaybackParameterDialog extends DialogFragment {
}
}
+ private void setupAdjustBySemitonesControl(@NonNull final View rootView) {
+ adjustBySemitonesCheckbox = rootView.findViewById(R.id.adjustBySemitonesCheckbox);
+ if (adjustBySemitonesCheckbox != null) {
+ // restores whether semitone adjustment is used or not
+ adjustBySemitonesCheckbox.setChecked(PreferenceManager
+ .getDefaultSharedPreferences(requireContext())
+ .getBoolean(getString(R.string.playback_adjust_by_semitones_key), true));
+
+ // stores whether semitone adjustment is used or not
+ adjustBySemitonesCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> {
+ PreferenceManager.getDefaultSharedPreferences(requireContext())
+ .edit()
+ .putBoolean(getString(R.string.playback_adjust_by_semitones_key), isChecked)
+ .apply();
+ togglePitchSliderType(rootView);
+ if (isChecked) {
+ setPlaybackParameters(
+ getCurrentTempo(),
+ getCurrentPitch(),
+ Integer.min(12,
+ Integer.max(-12, percentToSemitones(getCurrentPitch())
+ )),
+ getCurrentSkipSilence()
+ );
+ setSemitoneSlider(Integer.min(12,
+ Integer.max(-12, percentToSemitones(getCurrentPitch()))
+ ));
+ } else {
+ setPlaybackParameters(
+ getCurrentTempo(),
+ semitonesToPercent(getCurrentSemitones()),
+ getCurrentSemitones(),
+ getCurrentSkipSilence()
+ );
+ setPitchSlider(semitonesToPercent(getCurrentSemitones()));
+ }
+ });
+ }
+ }
+
private void setupStepSizeSelector(@NonNull final View rootView) {
+ setStepSize(PreferenceManager
+ .getDefaultSharedPreferences(requireContext())
+ .getFloat(getString(R.string.adjustment_step_key), (float) DEFAULT_STEP));
+
final TextView stepSizeOnePercentText = rootView.findViewById(R.id.stepSizeOnePercent);
final TextView stepSizeFivePercentText = rootView.findViewById(R.id.stepSizeFivePercent);
final TextView stepSizeTenPercentText = rootView.findViewById(R.id.stepSizeTenPercent);
@@ -310,8 +423,27 @@ public class PlaybackParameterDialog extends DialogFragment {
}
}
+ private void setupTempoStepSizeSelector(@NonNull final View rootView) {
+ final TextView playbackStepTypeText = rootView.findViewById(R.id.playback_step_type);
+ if (playbackStepTypeText != null) {
+ playbackStepTypeText.setText(R.string.playback_tempo_step);
+ }
+ setupStepSizeSelector(rootView);
+ }
+
+ private void setupCombinedStepSizeSelector(@NonNull final View rootView) {
+ final TextView playbackStepTypeText = rootView.findViewById(R.id.playback_step_type);
+ if (playbackStepTypeText != null) {
+ playbackStepTypeText.setText(R.string.playback_step);
+ }
+ setupStepSizeSelector(rootView);
+ }
+
private void setStepSize(final double stepSize) {
- this.stepSize = stepSize;
+ PreferenceManager.getDefaultSharedPreferences(requireContext())
+ .edit()
+ .putFloat(getString(R.string.adjustment_step_key), (float) stepSize)
+ .apply();
if (tempoStepUpText != null) {
tempoStepUpText.setText(getStepUpPercentString(stepSize));
@@ -344,16 +476,30 @@ public class PlaybackParameterDialog extends DialogFragment {
setCurrentPlaybackParameters();
});
}
+
+ if (semitoneStepDownText != null) {
+ semitoneStepDownText.setOnClickListener(view -> {
+ onSemitoneSliderUpdated(getCurrentSemitones() - 1);
+ setCurrentPlaybackParameters();
+ });
+ }
+
+ if (semitoneStepUpText != null) {
+ semitoneStepUpText.setOnClickListener(view -> {
+ onSemitoneSliderUpdated(getCurrentSemitones() + 1);
+ setCurrentPlaybackParameters();
+ });
+ }
}
/*//////////////////////////////////////////////////////////////////////////
// Sliders
//////////////////////////////////////////////////////////////////////////*/
- private SeekBar.OnSeekBarChangeListener getOnTempoChangedListener() {
- return new SeekBar.OnSeekBarChangeListener() {
+ private SimpleOnSeekBarChangeListener getOnTempoChangedListener() {
+ return new SimpleOnSeekBarChangeListener() {
@Override
- public void onProgressChanged(final SeekBar seekBar, final int progress,
+ public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress,
final boolean fromUser) {
final double currentTempo = strategy.valueOf(progress);
if (fromUser) {
@@ -361,23 +507,13 @@ public class PlaybackParameterDialog extends DialogFragment {
setCurrentPlaybackParameters();
}
}
-
- @Override
- public void onStartTrackingTouch(final SeekBar seekBar) {
- // Do Nothing.
- }
-
- @Override
- public void onStopTrackingTouch(final SeekBar seekBar) {
- // Do Nothing.
- }
};
}
- private SeekBar.OnSeekBarChangeListener getOnPitchChangedListener() {
- return new SeekBar.OnSeekBarChangeListener() {
+ private SimpleOnSeekBarChangeListener getOnPitchChangedListener() {
+ return new SimpleOnSeekBarChangeListener() {
@Override
- public void onProgressChanged(final SeekBar seekBar, final int progress,
+ public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress,
final boolean fromUser) {
final double currentPitch = strategy.valueOf(progress);
if (fromUser) { // this change is first in chain
@@ -385,23 +521,27 @@ public class PlaybackParameterDialog extends DialogFragment {
setCurrentPlaybackParameters();
}
}
+ };
+ }
+ private SimpleOnSeekBarChangeListener getOnSemitoneChangedListener() {
+ return new SimpleOnSeekBarChangeListener() {
@Override
- public void onStartTrackingTouch(final SeekBar seekBar) {
- // Do Nothing.
- }
-
- @Override
- public void onStopTrackingTouch(final SeekBar seekBar) {
- // Do Nothing.
+ public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress,
+ final boolean fromUser) {
+ // semitone slider supplies values 0 to 24, subtraction by 12 is required
+ final int currentSemitones = progress - 12;
+ if (fromUser) { // this change is first in chain
+ onSemitoneSliderUpdated(currentSemitones);
+ // line below also saves semitones as pitch percentages
+ onPitchSliderUpdated(semitonesToPercent(currentSemitones));
+ setCurrentPlaybackParameters();
+ }
}
};
}
private void onTempoSliderUpdated(final double newTempo) {
- if (unhookingCheckbox == null) {
- return;
- }
if (!unhookingCheckbox.isChecked()) {
setSliders(newTempo);
} else {
@@ -410,9 +550,6 @@ public class PlaybackParameterDialog extends DialogFragment {
}
private void onPitchSliderUpdated(final double newPitch) {
- if (unhookingCheckbox == null) {
- return;
- }
if (!unhookingCheckbox.isChecked()) {
setSliders(newPitch);
} else {
@@ -420,6 +557,10 @@ public class PlaybackParameterDialog extends DialogFragment {
}
}
+ private void onSemitoneSliderUpdated(final int newSemitone) {
+ setSemitoneSlider(newSemitone);
+ }
+
private void setSliders(final double newValue) {
setTempoSlider(newValue);
setPitchSlider(newValue);
@@ -439,25 +580,49 @@ public class PlaybackParameterDialog extends DialogFragment {
pitchSlider.setProgress(strategy.progressOf(newPitch));
}
+ private void setSemitoneSlider(final int newSemitone) {
+ if (semitoneSlider == null) {
+ return;
+ }
+ semitoneSlider.setProgress(newSemitone + 12);
+ }
+
/*//////////////////////////////////////////////////////////////////////////
// Helper
//////////////////////////////////////////////////////////////////////////*/
private void setCurrentPlaybackParameters() {
- setPlaybackParameters(getCurrentTempo(), getCurrentPitch(), getCurrentSkipSilence());
+ if (getCurrentAdjustBySemitones()) {
+ setPlaybackParameters(
+ getCurrentTempo(),
+ semitonesToPercent(getCurrentSemitones()),
+ getCurrentSemitones(),
+ getCurrentSkipSilence()
+ );
+ } else {
+ setPlaybackParameters(
+ getCurrentTempo(),
+ getCurrentPitch(),
+ percentToSemitones(getCurrentPitch()),
+ getCurrentSkipSilence()
+ );
+ }
}
private void setPlaybackParameters(final double newTempo, final double newPitch,
- final boolean skipSilence) {
- if (callback != null && tempoCurrentText != null && pitchCurrentText != null) {
+ final int newSemitones, final boolean skipSilence) {
+ if (callback != null && tempoCurrentText != null
+ && pitchCurrentText != null && semitoneCurrentText != null) {
if (DEBUG) {
Log.d(TAG, "Setting playback parameters to "
+ "tempo=[" + newTempo + "], "
- + "pitch=[" + newPitch + "]");
+ + "pitch=[" + newPitch + "], "
+ + "semitones=[" + newSemitones + "]");
}
tempoCurrentText.setText(PlayerHelper.formatSpeed(newTempo));
pitchCurrentText.setText(PlayerHelper.formatPitch(newPitch));
+ semitoneCurrentText.setText(getSignedSemitonesString(newSemitones));
callback.onPlaybackParameterChanged((float) newTempo, (float) newPitch, skipSilence);
}
}
@@ -470,14 +635,19 @@ public class PlaybackParameterDialog extends DialogFragment {
return pitchSlider == null ? pitch : strategy.valueOf(pitchSlider.getProgress());
}
- private double getCurrentStepSize() {
- return stepSize;
+ private int getCurrentSemitones() {
+ // semitoneSlider is absolute, that's why - 12
+ return semitoneSlider == null ? semitones : semitoneSlider.getProgress() - 12;
}
private boolean getCurrentSkipSilence() {
return skipSilenceCheckbox != null && skipSilenceCheckbox.isChecked();
}
+ private boolean getCurrentAdjustBySemitones() {
+ return adjustBySemitonesCheckbox != null && adjustBySemitonesCheckbox.isChecked();
+ }
+
@NonNull
private static String getStepUpPercentString(final double percent) {
return STEP_UP_SIGN + getPercentString(percent);
@@ -493,8 +663,21 @@ public class PlaybackParameterDialog extends DialogFragment {
return PlayerHelper.formatPitch(percent);
}
+ @NonNull
+ private static String getSignedSemitonesString(final int semitones) {
+ return semitones > 0 ? "+" + semitones : "" + semitones;
+ }
+
public interface Callback {
void onPlaybackParameterChanged(float playbackTempo, float playbackPitch,
boolean playbackSkipSilence);
}
+
+ public double semitonesToPercent(final int inSemitones) {
+ return Math.pow(2, inSemitones / 12.0);
+ }
+
+ public int percentToSemitones(final double inPercent) {
+ return (int) Math.round(12 * Math.log(inPercent) / Math.log(2));
+ }
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java
index a2f0d7149..405f6fd37 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java
@@ -2,8 +2,6 @@ package org.schabi.newpipe.player.helper;
import android.content.Context;
-import androidx.annotation.NonNull;
-
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.source.SingleSampleMediaSource;
import com.google.android.exoplayer2.source.dash.DashMediaSource;
@@ -13,10 +11,13 @@ import com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistTrack
import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource;
import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource;
import com.google.android.exoplayer2.upstream.DataSource;
-import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
+import com.google.android.exoplayer2.upstream.DefaultDataSource;
+import com.google.android.exoplayer2.upstream.DefaultHttpDataSource;
import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy;
import com.google.android.exoplayer2.upstream.TransferListener;
+import androidx.annotation.NonNull;
+
public class PlayerDataSource {
public static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000;
@@ -31,14 +32,18 @@ public class PlayerDataSource {
private static final int MANIFEST_MINIMUM_RETRY = 5;
private static final int EXTRACTOR_MINIMUM_RETRY = Integer.MAX_VALUE;
+ private final int continueLoadingCheckIntervalBytes;
private final DataSource.Factory cacheDataSourceFactory;
private final DataSource.Factory cachelessDataSourceFactory;
- public PlayerDataSource(@NonNull final Context context, @NonNull final String userAgent,
+ public PlayerDataSource(@NonNull final Context context,
+ @NonNull final String userAgent,
@NonNull final TransferListener transferListener) {
+ continueLoadingCheckIntervalBytes = PlayerHelper.getProgressiveLoadIntervalBytes(context);
cacheDataSourceFactory = new CacheFactory(context, userAgent, transferListener);
- cachelessDataSourceFactory
- = new DefaultDataSourceFactory(context, userAgent, transferListener);
+ cachelessDataSourceFactory = new DefaultDataSource
+ .Factory(context, new DefaultHttpDataSource.Factory().setUserAgent(userAgent))
+ .setTransferListener(transferListener);
}
public SsMediaSource.Factory getLiveSsMediaSourceFactory() {
@@ -91,6 +96,7 @@ public class PlayerDataSource {
public ProgressiveMediaSource.Factory getExtractorMediaSourceFactory() {
return new ProgressiveMediaSource.Factory(cacheDataSourceFactory)
+ .setContinueLoadingCheckIntervalBytes(continueLoadingCheckIntervalBytes)
.setLoadErrorHandlingPolicy(
new DefaultLoadErrorHandlingPolicy(EXTRACTOR_MINIMUM_RETRY));
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java
index c51b6d5dd..b73c6cf7f 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java
@@ -34,6 +34,7 @@ import androidx.preference.PreferenceManager;
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player.RepeatMode;
import com.google.android.exoplayer2.SeekParameters;
+import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection;
import com.google.android.exoplayer2.trackselection.ExoTrackSelection;
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
@@ -77,6 +78,20 @@ public final class PlayerHelper {
private static final NumberFormat SPEED_FORMATTER = new DecimalFormat("0.##x");
private static final NumberFormat PITCH_FORMATTER = new DecimalFormat("##%");
+ /**
+ * Maximum opacity allowed for Android 12 and higher to allow touches on other apps when using
+ * NewPipe's popup player.
+ *
+ *
+ * This value is hardcoded instead of being get dynamically with the method linked of the
+ * constant documentation below, because it is not static and popup player layout parameters
+ * are generated with static methods.
+ *
+ *
+ * @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE
+ */
+ private static final float MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER = 0.8f;
+
@Retention(SOURCE)
@IntDef({AUTOPLAY_TYPE_ALWAYS, AUTOPLAY_TYPE_WIFI,
AUTOPLAY_TYPE_NEVER})
@@ -143,6 +158,21 @@ public final class PlayerHelper {
? " (" + context.getString(R.string.caption_auto_generated) + ")" : "");
}
+ @NonNull
+ public static String captionLanguageStemOf(@NonNull final String language) {
+ if (!language.contains("(") || !language.contains(")")) {
+ return language;
+ }
+
+ if (language.startsWith("(")) {
+ // language text is right-to-left
+ final String[] parts = language.split("\\)");
+ return parts[parts.length - 1].trim();
+ }
+
+ return language.split("\\(")[0].trim();
+ }
+
@NonNull
public static String resizeTypeOf(@NonNull final Context context,
@ResizeMode final int resizeMode) {
@@ -391,6 +421,19 @@ public final class PlayerHelper {
context.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) == 0;
}
+ public static int getProgressiveLoadIntervalBytes(@NonNull final Context context) {
+ final String preferredIntervalBytes = getPreferences(context).getString(
+ context.getString(R.string.progressive_load_interval_key),
+ context.getString(R.string.progressive_load_interval_default_value));
+
+ if (context.getString(R.string.progressive_load_interval_exoplayer_default_value)
+ .equals(preferredIntervalBytes)) {
+ return ProgressiveMediaSource.DEFAULT_LOADING_CHECK_INTERVAL_BYTES;
+ }
+ // Keeping the same KiB unit used by ProgressiveMediaSource
+ return Integer.parseInt(preferredIntervalBytes) * 1024;
+ }
+
////////////////////////////////////////////////////////////////////////////
// Private helpers
////////////////////////////////////////////////////////////////////////////
@@ -558,6 +601,12 @@ public final class PlayerHelper {
flags,
PixelFormat.TRANSLUCENT);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ // Setting maximum opacity allowed for touch events to other apps for Android 12 and
+ // higher to prevent non interaction when using other apps with the popup player
+ closeOverlayLayoutParams.alpha = MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER;
+ }
+
closeOverlayLayoutParams.gravity = Gravity.LEFT | Gravity.TOP;
closeOverlayLayoutParams.softInputMode =
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
index 06a2e52ab..4c09ed3c1 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
@@ -10,7 +10,7 @@ import android.util.Log;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
-import com.google.android.exoplayer2.ExoPlaybackException;
+import com.google.android.exoplayer2.PlaybackException;
import com.google.android.exoplayer2.PlaybackParameters;
import org.schabi.newpipe.App;
@@ -233,9 +233,10 @@ public final class PlayerHolder {
}
@Override
- public void onPlayerError(final ExoPlaybackException error) {
+ public void onPlayerError(final PlaybackException error,
+ final boolean isCatchableException) {
if (listener != null) {
- listener.onPlayerError(error);
+ listener.onPlayerError(error, isCatchableException);
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/listeners/view/PlaybackSpeedClickListener.kt b/app/src/main/java/org/schabi/newpipe/player/listeners/view/PlaybackSpeedClickListener.kt
new file mode 100644
index 000000000..52eff5a1c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/listeners/view/PlaybackSpeedClickListener.kt
@@ -0,0 +1,47 @@
+package org.schabi.newpipe.player.listeners.view
+
+import android.util.Log
+import android.view.View
+import androidx.appcompat.widget.PopupMenu
+import org.schabi.newpipe.MainActivity
+import org.schabi.newpipe.player.Player
+import org.schabi.newpipe.player.helper.PlaybackParameterDialog
+
+/**
+ * Click listener for the playbackSpeed textview of the player
+ */
+class PlaybackSpeedClickListener(
+ private val player: Player,
+ private val playbackSpeedPopupMenu: PopupMenu
+) : View.OnClickListener {
+
+ companion object {
+ private const val TAG: String = "PlaybSpeedClickListener"
+ }
+
+ override fun onClick(v: View) {
+ if (MainActivity.DEBUG) {
+ Log.d(TAG, "onPlaybackSpeedClicked() called")
+ }
+
+ if (player.videoPlayerSelected()) {
+ PlaybackParameterDialog.newInstance(
+ player.playbackSpeed.toDouble(),
+ player.playbackPitch.toDouble(),
+ player.playbackSkipSilence
+ ) { speed: Float, pitch: Float, skipSilence: Boolean ->
+ player.setPlaybackParameters(
+ speed,
+ pitch,
+ skipSilence
+ )
+ }
+ .show(player.parentActivity!!.supportFragmentManager, null)
+ } else {
+ playbackSpeedPopupMenu.show()
+ player.isSomePopupMenuVisible = true
+ }
+
+ player.manageControlsAfterOnClick(v)
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/player/listeners/view/QualityClickListener.kt b/app/src/main/java/org/schabi/newpipe/player/listeners/view/QualityClickListener.kt
new file mode 100644
index 000000000..b103ac0e6
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/listeners/view/QualityClickListener.kt
@@ -0,0 +1,41 @@
+package org.schabi.newpipe.player.listeners.view
+
+import android.annotation.SuppressLint
+import android.util.Log
+import android.view.View
+import androidx.appcompat.widget.PopupMenu
+import org.schabi.newpipe.MainActivity
+import org.schabi.newpipe.extractor.MediaFormat
+import org.schabi.newpipe.player.Player
+
+/**
+ * Click listener for the qualityTextView of the player
+ */
+class QualityClickListener(
+ private val player: Player,
+ private val qualityPopupMenu: PopupMenu
+) : View.OnClickListener {
+
+ companion object {
+ private const val TAG: String = "QualityClickListener"
+ }
+
+ @SuppressLint("SetTextI18n") // we don't need I18N because of a " "
+ override fun onClick(v: View) {
+ if (MainActivity.DEBUG) {
+ Log.d(TAG, "onQualitySelectorClicked() called")
+ }
+
+ qualityPopupMenu.show()
+ player.isSomePopupMenuVisible = true
+
+ val videoStream = player.selectedVideoStream
+ if (videoStream != null) {
+ player.binding.qualityTextView.text =
+ MediaFormat.getNameById(videoStream.formatId) + " " + videoStream.resolution
+ }
+
+ player.saveWasPlaying()
+ player.manageControlsAfterOnClick(v)
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.java
new file mode 100644
index 000000000..ebedf8c71
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.java
@@ -0,0 +1,99 @@
+package org.schabi.newpipe.player.mediaitem;
+
+import org.schabi.newpipe.extractor.stream.StreamInfo;
+import org.schabi.newpipe.extractor.stream.StreamType;
+import org.schabi.newpipe.player.playqueue.PlayQueueItem;
+
+import java.util.List;
+import java.util.Optional;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/**
+ * This {@link MediaItemTag} object is designed to contain metadata for a stream
+ * that has failed to load. It supplies metadata from an underlying
+ * {@link PlayQueueItem}, which is used by the internal players to resolve actual
+ * playback info.
+ *
+ * This {@link MediaItemTag} does not contain any {@link StreamInfo} that can be
+ * used to start playback and can be detected by checking {@link ExceptionTag#getErrors()}
+ * when in generic form.
+ **/
+public final class ExceptionTag implements MediaItemTag {
+ @NonNull
+ private final PlayQueueItem item;
+ @NonNull
+ private final List errors;
+ @Nullable
+ private final Object extras;
+
+ private ExceptionTag(@NonNull final PlayQueueItem item,
+ @NonNull final List errors,
+ @Nullable final Object extras) {
+ this.item = item;
+ this.errors = errors;
+ this.extras = extras;
+ }
+
+ public static ExceptionTag of(@NonNull final PlayQueueItem playQueueItem,
+ @NonNull final List errors) {
+ return new ExceptionTag(playQueueItem, errors, null);
+ }
+
+ @NonNull
+ @Override
+ public List getErrors() {
+ return errors;
+ }
+
+ @Override
+ public int getServiceId() {
+ return item.getServiceId();
+ }
+
+ @Override
+ public String getTitle() {
+ return item.getTitle();
+ }
+
+ @Override
+ public String getUploaderName() {
+ return item.getUploader();
+ }
+
+ @Override
+ public long getDurationSeconds() {
+ return item.getDuration();
+ }
+
+ @Override
+ public String getStreamUrl() {
+ return item.getUrl();
+ }
+
+ @Override
+ public String getThumbnailUrl() {
+ return item.getThumbnailUrl();
+ }
+
+ @Override
+ public String getUploaderUrl() {
+ return item.getUploaderUrl();
+ }
+
+ @Override
+ public StreamType getStreamType() {
+ return item.getStreamType();
+ }
+
+ @Override
+ public Optional getMaybeExtras(@NonNull final Class type) {
+ return Optional.ofNullable(extras).map(type::cast);
+ }
+
+ @Override
+ public MediaItemTag withExtras(@NonNull final T extra) {
+ return new ExceptionTag(item, errors, extra);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java
new file mode 100644
index 000000000..f84b0383a
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java
@@ -0,0 +1,127 @@
+package org.schabi.newpipe.player.mediaitem;
+
+import android.net.Uri;
+
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.MediaMetadata;
+import com.google.android.exoplayer2.Player;
+
+import org.schabi.newpipe.extractor.stream.StreamInfo;
+import org.schabi.newpipe.extractor.stream.StreamType;
+import org.schabi.newpipe.extractor.stream.VideoStream;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/**
+ * Metadata container and accessor used by player internals.
+ *
+ * This interface ensures consistency of fetching metadata on each stream,
+ * which is encapsulated in a {@link MediaItem} and delivered via ExoPlayer's
+ * {@link Player.Listener} on event triggers to the downstream users.
+ **/
+public interface MediaItemTag {
+
+ List getErrors();
+
+ int getServiceId();
+
+ String getTitle();
+
+ String getUploaderName();
+
+ long getDurationSeconds();
+
+ String getStreamUrl();
+
+ String getThumbnailUrl();
+
+ String getUploaderUrl();
+
+ StreamType getStreamType();
+
+ @NonNull
+ default Optional getMaybeStreamInfo() {
+ return Optional.empty();
+ }
+
+ @NonNull
+ default Optional getMaybeQuality() {
+ return Optional.empty();
+ }
+
+ Optional getMaybeExtras(@NonNull Class type);
+
+ MediaItemTag withExtras(@NonNull T extra);
+
+ @NonNull
+ static Optional from(@Nullable final MediaItem mediaItem) {
+ if (mediaItem == null || mediaItem.localConfiguration == null
+ || !(mediaItem.localConfiguration.tag instanceof MediaItemTag)) {
+ return Optional.empty();
+ }
+
+ return Optional.of((MediaItemTag) mediaItem.localConfiguration.tag);
+ }
+
+ @NonNull
+ default String makeMediaId() {
+ return UUID.randomUUID().toString() + "[" + getTitle() + "]";
+ }
+
+ @NonNull
+ default MediaItem asMediaItem() {
+ final MediaMetadata mediaMetadata = new MediaMetadata.Builder()
+ .setMediaUri(Uri.parse(getStreamUrl()))
+ .setArtworkUri(Uri.parse(getThumbnailUrl()))
+ .setArtist(getUploaderName())
+ .setDescription(getTitle())
+ .setDisplayTitle(getTitle())
+ .setTitle(getTitle())
+ .build();
+
+ return MediaItem.fromUri(getStreamUrl())
+ .buildUpon()
+ .setMediaId(makeMediaId())
+ .setMediaMetadata(mediaMetadata)
+ .setTag(this)
+ .build();
+ }
+
+ final class Quality {
+ @NonNull
+ private final List sortedVideoStreams;
+ private final int selectedVideoStreamIndex;
+
+ private Quality(@NonNull final List sortedVideoStreams,
+ final int selectedVideoStreamIndex) {
+ this.sortedVideoStreams = sortedVideoStreams;
+ this.selectedVideoStreamIndex = selectedVideoStreamIndex;
+ }
+
+ static Quality of(@NonNull final List sortedVideoStreams,
+ final int selectedVideoStreamIndex) {
+ return new Quality(sortedVideoStreams, selectedVideoStreamIndex);
+ }
+
+ @NonNull
+ public List getSortedVideoStreams() {
+ return sortedVideoStreams;
+ }
+
+ public int getSelectedVideoStreamIndex() {
+ return selectedVideoStreamIndex;
+ }
+
+ @Nullable
+ public VideoStream getSelectedVideoStream() {
+ return selectedVideoStreamIndex < 0
+ || selectedVideoStreamIndex >= sortedVideoStreams.size()
+ ? null : sortedVideoStreams.get(selectedVideoStreamIndex);
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/PlaceholderTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/PlaceholderTag.java
new file mode 100644
index 000000000..cce4e9f17
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/PlaceholderTag.java
@@ -0,0 +1,85 @@
+package org.schabi.newpipe.player.mediaitem;
+
+import org.schabi.newpipe.extractor.stream.StreamType;
+import org.schabi.newpipe.util.Constants;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/**
+ * This is a Placeholding {@link MediaItemTag}, designed as a dummy metadata object for
+ * any stream that has not been resolved.
+ *
+ * This object cannot be instantiated and does not hold real metadata of any form.
+ * */
+public final class PlaceholderTag implements MediaItemTag {
+ public static final PlaceholderTag EMPTY = new PlaceholderTag(null);
+ private static final String UNKNOWN_VALUE_INTERNAL = "Placeholder";
+
+ @Nullable
+ private final Object extras;
+
+ private PlaceholderTag(@Nullable final Object extras) {
+ this.extras = extras;
+ }
+
+ @NonNull
+ @Override
+ public List getErrors() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public int getServiceId() {
+ return Constants.NO_SERVICE_ID;
+ }
+
+ @Override
+ public String getTitle() {
+ return UNKNOWN_VALUE_INTERNAL;
+ }
+
+ @Override
+ public String getUploaderName() {
+ return UNKNOWN_VALUE_INTERNAL;
+ }
+
+ @Override
+ public long getDurationSeconds() {
+ return 0;
+ }
+
+ @Override
+ public String getStreamUrl() {
+ return UNKNOWN_VALUE_INTERNAL;
+ }
+
+ @Override
+ public String getThumbnailUrl() {
+ return UNKNOWN_VALUE_INTERNAL;
+ }
+
+ @Override
+ public String getUploaderUrl() {
+ return UNKNOWN_VALUE_INTERNAL;
+ }
+
+ @Override
+ public StreamType getStreamType() {
+ return StreamType.NONE;
+ }
+
+ @Override
+ public Optional getMaybeExtras(@NonNull final Class type) {
+ return Optional.ofNullable(extras).map(type::cast);
+ }
+
+ @Override
+ public MediaItemTag withExtras(@NonNull final T extra) {
+ return new PlaceholderTag(extra);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java
new file mode 100644
index 000000000..4095f2bc8
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java
@@ -0,0 +1,115 @@
+package org.schabi.newpipe.player.mediaitem;
+
+import com.google.android.exoplayer2.MediaItem;
+
+import org.schabi.newpipe.extractor.stream.StreamInfo;
+import org.schabi.newpipe.extractor.stream.StreamType;
+import org.schabi.newpipe.extractor.stream.VideoStream;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/**
+ * This {@link MediaItemTag} object contains metadata for a resolved stream
+ * that is ready for playback. This object guarantees the {@link StreamInfo}
+ * is available and may provide the {@link Quality} of video stream used in
+ * the {@link MediaItem}.
+ **/
+public final class StreamInfoTag implements MediaItemTag {
+ @NonNull
+ private final StreamInfo streamInfo;
+ @Nullable
+ private final MediaItemTag.Quality quality;
+ @Nullable
+ private final Object extras;
+
+ private StreamInfoTag(@NonNull final StreamInfo streamInfo,
+ @Nullable final MediaItemTag.Quality quality,
+ @Nullable final Object extras) {
+ this.streamInfo = streamInfo;
+ this.quality = quality;
+ this.extras = extras;
+ }
+
+ public static StreamInfoTag of(@NonNull final StreamInfo streamInfo,
+ @NonNull final List sortedVideoStreams,
+ final int selectedVideoStreamIndex) {
+ final Quality quality = Quality.of(sortedVideoStreams, selectedVideoStreamIndex);
+ return new StreamInfoTag(streamInfo, quality, null);
+ }
+
+ public static StreamInfoTag of(@NonNull final StreamInfo streamInfo) {
+ return new StreamInfoTag(streamInfo, null, null);
+ }
+
+ @Override
+ public List getErrors() {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public int getServiceId() {
+ return streamInfo.getServiceId();
+ }
+
+ @Override
+ public String getTitle() {
+ return streamInfo.getName();
+ }
+
+ @Override
+ public String getUploaderName() {
+ return streamInfo.getUploaderName();
+ }
+
+ @Override
+ public long getDurationSeconds() {
+ return streamInfo.getDuration();
+ }
+
+ @Override
+ public String getStreamUrl() {
+ return streamInfo.getUrl();
+ }
+
+ @Override
+ public String getThumbnailUrl() {
+ return streamInfo.getThumbnailUrl();
+ }
+
+ @Override
+ public String getUploaderUrl() {
+ return streamInfo.getUploaderUrl();
+ }
+
+ @Override
+ public StreamType getStreamType() {
+ return streamInfo.getStreamType();
+ }
+
+ @NonNull
+ @Override
+ public Optional getMaybeStreamInfo() {
+ return Optional.of(streamInfo);
+ }
+
+ @NonNull
+ @Override
+ public Optional getMaybeQuality() {
+ return Optional.ofNullable(quality);
+ }
+
+ @Override
+ public Optional getMaybeExtras(@NonNull final Class type) {
+ return Optional.ofNullable(extras).map(type::cast);
+ }
+
+ @Override
+ public StreamInfoTag withExtras(@NonNull final Object extra) {
+ return new StreamInfoTag(streamInfo, quality, extra);
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java
index 62664c827..92cd425c5 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java
+++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java
@@ -7,7 +7,6 @@ import android.support.v4.media.session.MediaSessionCompat;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import com.google.android.exoplayer2.ControlDispatcher;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import com.google.android.exoplayer2.util.Util;
@@ -44,17 +43,17 @@ public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator
}
@Override
- public void onTimelineChanged(final Player player) {
+ public void onTimelineChanged(@NonNull final Player player) {
publishFloatingQueueWindow();
}
@Override
- public void onCurrentWindowIndexChanged(final Player player) {
+ public void onCurrentMediaItemIndexChanged(@NonNull final Player player) {
if (activeQueueItemId == MediaSessionCompat.QueueItem.UNKNOWN_ID
|| player.getCurrentTimeline().getWindowCount() > maxQueueSize) {
publishFloatingQueueWindow();
} else if (!player.getCurrentTimeline().isEmpty()) {
- activeQueueItemId = player.getCurrentWindowIndex();
+ activeQueueItemId = player.getCurrentMediaItemIndex();
}
}
@@ -64,18 +63,17 @@ public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator
}
@Override
- public void onSkipToPrevious(final Player player, final ControlDispatcher controlDispatcher) {
+ public void onSkipToPrevious(@NonNull final Player player) {
callback.playPrevious();
}
@Override
- public void onSkipToQueueItem(final Player player, final ControlDispatcher controlDispatcher,
- final long id) {
+ public void onSkipToQueueItem(@NonNull final Player player, final long id) {
callback.playItemAtIndex((int) id);
}
@Override
- public void onSkipToNext(final Player player, final ControlDispatcher controlDispatcher) {
+ public void onSkipToNext(@NonNull final Player player) {
callback.playNext();
}
@@ -102,8 +100,10 @@ public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator
}
@Override
- public boolean onCommand(final Player player, final ControlDispatcher controlDispatcher,
- final String command, final Bundle extras, final ResultReceiver cb) {
+ public boolean onCommand(@NonNull final Player player,
+ @NonNull final String command,
+ @Nullable final Bundle extras,
+ @Nullable final ResultReceiver cb) {
return false;
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueuePlaybackController.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueuePlaybackController.java
deleted file mode 100644
index 8bfbcde6b..000000000
--- a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueuePlaybackController.java
+++ /dev/null
@@ -1,23 +0,0 @@
-package org.schabi.newpipe.player.mediasession;
-
-import com.google.android.exoplayer2.DefaultControlDispatcher;
-import com.google.android.exoplayer2.Player;
-
-public class PlayQueuePlaybackController extends DefaultControlDispatcher {
- private final MediaSessionCallback callback;
-
- public PlayQueuePlaybackController(final MediaSessionCallback callback) {
- super();
- this.callback = callback;
- }
-
- @Override
- public boolean dispatchSetPlayWhenReady(final Player player, final boolean playWhenReady) {
- if (playWhenReady) {
- callback.play();
- } else {
- callback.pause();
- }
- return true;
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java
index 7594f3a16..8aad356d0 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java
+++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java
@@ -2,52 +2,83 @@ package org.schabi.newpipe.player.mediasource;
import android.util.Log;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.PlaybackException;
+import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.source.BaseMediaSource;
import com.google.android.exoplayer2.source.MediaPeriod;
+import com.google.android.exoplayer2.source.SilenceMediaSource;
+import com.google.android.exoplayer2.source.SinglePeriodTimeline;
import com.google.android.exoplayer2.upstream.Allocator;
import com.google.android.exoplayer2.upstream.TransferListener;
+import org.schabi.newpipe.player.mediaitem.ExceptionTag;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import java.io.IOException;
+import java.util.Collections;
+import java.util.concurrent.TimeUnit;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
public class FailedMediaSource extends BaseMediaSource implements ManagedMediaSource {
+ /**
+ * Play 2 seconds of silenced audio when a stream fails to resolve due to a known issue,
+ * such as {@link org.schabi.newpipe.extractor.exceptions.ExtractionException}.
+ *
+ * This silence duration allows user to react and have time to jump to a previous stream,
+ * while still provide a smooth playback experience. A duration lower than 1 second is
+ * not recommended, it may cause ExoPlayer to buffer for a while.
+ * */
+ public static final long SILENCE_DURATION_US = TimeUnit.SECONDS.toMicros(2);
+ public static final MediaPeriod SILENT_MEDIA = makeSilentMediaPeriod(SILENCE_DURATION_US);
+
private final String TAG = "FailedMediaSource@" + Integer.toHexString(hashCode());
private final PlayQueueItem playQueueItem;
- private final FailedMediaSourceException error;
+ private final Exception error;
private final long retryTimestamp;
-
+ private final MediaItem mediaItem;
+ /**
+ * Fail the play queue item associated with this source, with potential future retries.
+ *
+ * The error will be propagated if the cause for load exception is unspecified.
+ * This means the error might be caused by reasons outside of extraction (e.g. no network).
+ * Otherwise, a silenced stream will play instead.
+ *
+ * @param playQueueItem play queue item
+ * @param error exception that was the reason to fail
+ * @param retryTimestamp epoch timestamp when this MediaSource can be refreshed
+ */
public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem,
- @NonNull final FailedMediaSourceException error,
+ @NonNull final Exception error,
final long retryTimestamp) {
this.playQueueItem = playQueueItem;
this.error = error;
this.retryTimestamp = retryTimestamp;
+ this.mediaItem = ExceptionTag
+ .of(playQueueItem, Collections.singletonList(error))
+ .withExtras(this)
+ .asMediaItem();
}
- /**
- * Permanently fail the play queue item associated with this source, with no hope of retrying.
- * The error will always be propagated to ExoPlayer.
- *
- * @param playQueueItem play queue item
- * @param error exception that was the reason to fail
- */
- public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem,
- @NonNull final FailedMediaSourceException error) {
- this.playQueueItem = playQueueItem;
- this.error = error;
- this.retryTimestamp = Long.MAX_VALUE;
+ public static FailedMediaSource of(@NonNull final PlayQueueItem playQueueItem,
+ @NonNull final FailedMediaSourceException error) {
+ return new FailedMediaSource(playQueueItem, error, Long.MAX_VALUE);
+ }
+
+ public static FailedMediaSource of(@NonNull final PlayQueueItem playQueueItem,
+ @NonNull final Exception error,
+ final long retryWaitMillis) {
+ return new FailedMediaSource(playQueueItem, error,
+ System.currentTimeMillis() + retryWaitMillis);
}
public PlayQueueItem getStream() {
return playQueueItem;
}
- public FailedMediaSourceException getError() {
+ public Exception getError() {
return error;
}
@@ -55,35 +86,78 @@ public class FailedMediaSource extends BaseMediaSource implements ManagedMediaSo
return System.currentTimeMillis() >= retryTimestamp;
}
- /**
- * Returns the {@link MediaItem} whose media is provided by the source.
- */
@Override
public MediaItem getMediaItem() {
- return MediaItem.fromUri(playQueueItem.getUrl());
+ return mediaItem;
}
- @Override
- public void maybeThrowSourceInfoRefreshError() throws IOException {
- throw new IOException(error);
- }
-
- @Override
- public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator,
- final long startPositionUs) {
- return null;
- }
-
- @Override
- public void releasePeriod(final MediaPeriod mediaPeriod) { }
-
+ /**
+ * Prepares the source with {@link Timeline} info on the silence playback when the error
+ * is classed as {@link FailedMediaSourceException}, for example, when the error is
+ * {@link org.schabi.newpipe.extractor.exceptions.ExtractionException ExtractionException}.
+ * These types of error are swallowed by {@link FailedMediaSource}, and the underlying
+ * exception is carried to the {@link MediaItem} metadata during playback.
+ *
+ * If the exception is not known, e.g. {@link java.net.UnknownHostException} or some
+ * other network issue, then no source info is refreshed and
+ * {@link #maybeThrowSourceInfoRefreshError()} be will triggered.
+ *
+ * Note that this method is called only once until {@link #releaseSourceInternal()} is called,
+ * so if no action is done in here, playback will stall unless
+ * {@link #maybeThrowSourceInfoRefreshError()} is called.
+ *
+ * @param mediaTransferListener No data transfer listener needed, ignored here.
+ */
@Override
protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) {
Log.e(TAG, "Loading failed source: ", error);
+ if (error instanceof FailedMediaSourceException) {
+ refreshSourceInfo(makeSilentMediaTimeline(SILENCE_DURATION_US, mediaItem));
+ }
+ }
+
+ /**
+ * If the error is not known, e.g. network issue, then the exception is not swallowed here in
+ * {@link FailedMediaSource}. The exception is then propagated to the player, which
+ * {@link org.schabi.newpipe.player.Player Player} can react to inside
+ * {@link com.google.android.exoplayer2.Player.Listener#onPlayerError(PlaybackException)}.
+ *
+ * @throws IOException An error which will always result in
+ * {@link com.google.android.exoplayer2.PlaybackException#ERROR_CODE_IO_UNSPECIFIED}.
+ */
+ @Override
+ public void maybeThrowSourceInfoRefreshError() throws IOException {
+ if (!(error instanceof FailedMediaSourceException)) {
+ throw new IOException(error);
+ }
+ }
+
+ /**
+ * This method is only called if {@link #prepareSourceInternal(TransferListener)}
+ * refreshes the source info with no exception. All parameters are ignored as this
+ * returns a static and reused piece of silent audio.
+ *
+ * @param id The identifier of the period.
+ * @param allocator An {@link Allocator} from which to obtain media buffer allocations.
+ * @param startPositionUs The expected start position, in microseconds.
+ * @return The common {@link MediaPeriod} holding the silence.
+ */
+ @Override
+ public MediaPeriod createPeriod(final MediaPeriodId id,
+ final Allocator allocator,
+ final long startPositionUs) {
+ return SILENT_MEDIA;
}
@Override
- protected void releaseSourceInternal() { }
+ public void releasePeriod(final MediaPeriod mediaPeriod) {
+ /* Do Nothing (we want to keep re-using the Silent MediaPeriod) */
+ }
+
+ @Override
+ protected void releaseSourceInternal() {
+ /* Do Nothing, no clean-up for processing/extra thread is needed by this MediaSource */
+ }
@Override
public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity,
@@ -117,4 +191,22 @@ public class FailedMediaSource extends BaseMediaSource implements ManagedMediaSo
super(cause);
}
}
+
+ private static Timeline makeSilentMediaTimeline(final long durationUs,
+ @NonNull final MediaItem mediaItem) {
+ return new SinglePeriodTimeline(
+ durationUs,
+ /* isSeekable= */ true,
+ /* isDynamic= */ false,
+ /* useLiveConfiguration= */ false,
+ /* manifest= */ null,
+ mediaItem);
+ }
+
+ private static MediaPeriod makeSilentMediaPeriod(final long durationUs) {
+ return new SilenceMediaSource.Factory()
+ .setDurationUs(durationUs)
+ .createMediaSource()
+ .createPeriod(null, null, 0);
+ }
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java
index 746a97581..95524cf69 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java
+++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java
@@ -1,32 +1,46 @@
package org.schabi.newpipe.player.mediasource;
-import android.os.Handler;
+import com.google.android.exoplayer2.MediaItem;
+import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.source.CompositeMediaSource;
+import com.google.android.exoplayer2.source.MediaPeriod;
+import com.google.android.exoplayer2.source.MediaSource;
+import com.google.android.exoplayer2.upstream.Allocator;
+import com.google.android.exoplayer2.upstream.TransferListener;
+
+import org.schabi.newpipe.player.mediaitem.MediaItemTag;
+import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import com.google.android.exoplayer2.MediaItem;
-import com.google.android.exoplayer2.drm.DrmSessionEventListener;
-import com.google.android.exoplayer2.source.MediaPeriod;
-import com.google.android.exoplayer2.source.MediaSource;
-import com.google.android.exoplayer2.source.MediaSourceEventListener;
-import com.google.android.exoplayer2.upstream.Allocator;
-import com.google.android.exoplayer2.upstream.TransferListener;
-
-import org.schabi.newpipe.player.playqueue.PlayQueueItem;
-
-import java.io.IOException;
-
-public class LoadedMediaSource implements ManagedMediaSource {
+public class LoadedMediaSource extends CompositeMediaSource implements ManagedMediaSource {
private final MediaSource source;
private final PlayQueueItem stream;
+ private final MediaItem mediaItem;
private final long expireTimestamp;
- public LoadedMediaSource(@NonNull final MediaSource source, @NonNull final PlayQueueItem stream,
+ /**
+ * Uses a {@link CompositeMediaSource} to wrap one or more child {@link MediaSource}s
+ * containing actual media. This wrapper {@link LoadedMediaSource} holds the expiration
+ * timestamp as a {@link ManagedMediaSource} to allow explicit playlist management under
+ * {@link ManagedMediaSourcePlaylist}.
+ *
+ * @param source The child media source with actual media.
+ * @param tag Metadata for the child media source.
+ * @param stream The queue item associated with the media source.
+ * @param expireTimestamp The timestamp when the media source expires and might not be
+ * available for playback.
+ */
+ public LoadedMediaSource(@NonNull final MediaSource source,
+ @NonNull final MediaItemTag tag,
+ @NonNull final PlayQueueItem stream,
final long expireTimestamp) {
this.source = source;
this.stream = stream;
this.expireTimestamp = expireTimestamp;
+
+ this.mediaItem = tag.withExtras(this).asMediaItem();
}
public PlayQueueItem getStream() {
@@ -37,20 +51,38 @@ public class LoadedMediaSource implements ManagedMediaSource {
return System.currentTimeMillis() >= expireTimestamp;
}
+ /**
+ * Delegates the preparation of child {@link MediaSource}s to the
+ * {@link CompositeMediaSource} wrapper. Since all {@link LoadedMediaSource}s use only
+ * a single child media, the child id of 0 is always used (sonar doesn't like null as id here).
+ *
+ * @param mediaTransferListener A data transfer listener that will be registered by the
+ * {@link CompositeMediaSource} for child source preparation.
+ */
@Override
- public void prepareSource(final MediaSourceCaller mediaSourceCaller,
- @Nullable final TransferListener mediaTransferListener) {
- source.prepareSource(mediaSourceCaller, mediaTransferListener);
+ protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) {
+ super.prepareSourceInternal(mediaTransferListener);
+ prepareChildSource(0, source);
}
+ /**
+ * When any child {@link MediaSource} is prepared, the refreshed {@link Timeline} can
+ * be listened to here. But since {@link LoadedMediaSource} has only a single child source,
+ * this method is called only once until {@link #releaseSourceInternal()} is called.
+ *
+ * On refresh, the {@link CompositeMediaSource} delegate will be notified with the
+ * new {@link Timeline}, otherwise {@link #createPeriod(MediaPeriodId, Allocator, long)}
+ * will not be called and playback may be stalled.
+ *
+ * @param id The unique id used to prepare the child source.
+ * @param mediaSource The child source whose source info has been refreshed.
+ * @param timeline The new timeline of the child source.
+ */
@Override
- public void maybeThrowSourceInfoRefreshError() throws IOException {
- source.maybeThrowSourceInfoRefreshError();
- }
-
- @Override
- public void enable(final MediaSourceCaller caller) {
- source.enable(caller);
+ protected void onChildSourceInfoRefreshed(final Integer id,
+ final MediaSource mediaSource,
+ final Timeline timeline) {
+ refreshSourceInfo(timeline);
}
@Override
@@ -64,57 +96,10 @@ public class LoadedMediaSource implements ManagedMediaSource {
source.releasePeriod(mediaPeriod);
}
- @Override
- public void disable(final MediaSourceCaller caller) {
- source.disable(caller);
- }
-
- @Override
- public void releaseSource(final MediaSourceCaller mediaSourceCaller) {
- source.releaseSource(mediaSourceCaller);
- }
-
- @Override
- public void addEventListener(final Handler handler,
- final MediaSourceEventListener eventListener) {
- source.addEventListener(handler, eventListener);
- }
-
- @Override
- public void removeEventListener(final MediaSourceEventListener eventListener) {
- source.removeEventListener(eventListener);
- }
-
- /**
- * Adds a {@link DrmSessionEventListener} to the list of listeners which are notified of DRM
- * events for this media source.
- *
- * @param handler A handler on the which listener events will be posted.
- * @param eventListener The listener to be added.
- */
- @Override
- public void addDrmEventListener(final Handler handler,
- final DrmSessionEventListener eventListener) {
- source.addDrmEventListener(handler, eventListener);
- }
-
- /**
- * Removes a {@link DrmSessionEventListener} from the list of listeners which are notified of
- * DRM events for this media source.
- *
- * @param eventListener The listener to be removed.
- */
- @Override
- public void removeDrmEventListener(final DrmSessionEventListener eventListener) {
- source.removeDrmEventListener(eventListener);
- }
-
- /**
- * Returns the {@link MediaItem} whose media is provided by the source.
- */
+ @NonNull
@Override
public MediaItem getMediaItem() {
- return source.getMediaItem();
+ return mediaItem;
}
@Override
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java
index 21fddbe86..9d6b94893 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java
+++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java
@@ -1,7 +1,6 @@
package org.schabi.newpipe.player.mediasource;
import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
import com.google.android.exoplayer2.source.MediaSource;
@@ -28,10 +27,4 @@ public interface ManagedMediaSource extends MediaSource {
* @return whether this source is for the specified stream
*/
boolean isStreamEqual(@NonNull PlayQueueItem stream);
-
- @Nullable
- @Override
- default Object getTag() {
- return this;
- }
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java
index ff0cf21fa..4c0380767 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java
+++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java
@@ -8,6 +8,8 @@ import androidx.annotation.Nullable;
import com.google.android.exoplayer2.source.ConcatenatingMediaSource;
import com.google.android.exoplayer2.source.ShuffleOrder;
+import org.schabi.newpipe.player.mediaitem.MediaItemTag;
+
public class ManagedMediaSourcePlaylist {
@NonNull
private final ConcatenatingMediaSource internalSource;
@@ -34,8 +36,14 @@ public class ManagedMediaSourcePlaylist {
*/
@Nullable
public ManagedMediaSource get(final int index) {
- return (index < 0 || index >= size())
- ? null : (ManagedMediaSource) internalSource.getMediaSource(index).getTag();
+ if (index < 0 || index >= size()) {
+ return null;
+ }
+
+ return MediaItemTag
+ .from(internalSource.getMediaSource(index).getMediaItem())
+ .flatMap(tag -> tag.getMaybeExtras(ManagedMediaSource.class))
+ .orElse(null);
}
@NonNull
@@ -54,7 +62,7 @@ public class ManagedMediaSourcePlaylist {
* @see #append(ManagedMediaSource)
*/
public synchronized void expand() {
- append(new PlaceholderMediaSource());
+ append(PlaceholderMediaSource.COPY);
}
/**
@@ -115,10 +123,10 @@ public class ManagedMediaSourcePlaylist {
public synchronized void invalidate(final int index,
@Nullable final Handler handler,
@Nullable final Runnable finalizingAction) {
- if (get(index) instanceof PlaceholderMediaSource) {
+ if (get(index) == PlaceholderMediaSource.COPY) {
return;
}
- update(index, new PlaceholderMediaSource(), handler, finalizingAction);
+ update(index, PlaceholderMediaSource.COPY, handler, finalizingAction);
}
/**
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java
index 1cd855627..92d4403c8 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java
+++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java
@@ -1,28 +1,35 @@
package org.schabi.newpipe.player.mediasource;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
import com.google.android.exoplayer2.MediaItem;
-import com.google.android.exoplayer2.source.BaseMediaSource;
+import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.source.CompositeMediaSource;
import com.google.android.exoplayer2.source.MediaPeriod;
+import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.upstream.Allocator;
-import com.google.android.exoplayer2.upstream.TransferListener;
+import org.schabi.newpipe.player.mediaitem.PlaceholderTag;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
-public class PlaceholderMediaSource extends BaseMediaSource implements ManagedMediaSource {
- /**
- * Returns the {@link MediaItem} whose media is provided by the source.
- */
+import androidx.annotation.NonNull;
+
+final class PlaceholderMediaSource
+ extends CompositeMediaSource implements ManagedMediaSource {
+ public static final PlaceholderMediaSource COPY = new PlaceholderMediaSource();
+ private static final MediaItem MEDIA_ITEM = PlaceholderTag.EMPTY.withExtras(COPY).asMediaItem();
+
+ private PlaceholderMediaSource() { }
+
@Override
public MediaItem getMediaItem() {
- return null;
+ return MEDIA_ITEM;
}
- // Do nothing, so this will stall the playback
@Override
- public void maybeThrowSourceInfoRefreshError() { }
+ protected void onChildSourceInfoRefreshed(final Void id,
+ final MediaSource mediaSource,
+ final Timeline timeline) {
+ /* Do nothing, no timeline updates or error will stall playback */
+ }
@Override
public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator,
@@ -33,12 +40,6 @@ public class PlaceholderMediaSource extends BaseMediaSource implements ManagedMe
@Override
public void releasePeriod(final MediaPeriod mediaPeriod) { }
- @Override
- protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) { }
-
- @Override
- protected void releaseSourceInternal() { }
-
@Override
public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity,
final boolean isInterruptable) {
diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java b/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java
deleted file mode 100644
index 389be7062..000000000
--- a/app/src/main/java/org/schabi/newpipe/player/playback/CustomTrackSelector.java
+++ /dev/null
@@ -1,92 +0,0 @@
-package org.schabi.newpipe.player.playback;
-
-import android.content.Context;
-import android.text.TextUtils;
-import android.util.Pair;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import com.google.android.exoplayer2.C;
-import com.google.android.exoplayer2.Format;
-import com.google.android.exoplayer2.RendererCapabilities.Capabilities;
-import com.google.android.exoplayer2.source.TrackGroup;
-import com.google.android.exoplayer2.source.TrackGroupArray;
-import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
-import com.google.android.exoplayer2.trackselection.ExoTrackSelection;
-import com.google.android.exoplayer2.util.Assertions;
-
-/**
- * This class allows irregular text language labels for use when selecting text captions and
- * is mostly a copy-paste from {@link DefaultTrackSelector}.
- *
- * This is a hack and should be removed once ExoPlayer fixes language normalization to accept
- * a broader set of languages.
- *
- */
-public class CustomTrackSelector extends DefaultTrackSelector {
- private String preferredTextLanguage;
-
- public CustomTrackSelector(final Context context,
- final ExoTrackSelection.Factory adaptiveTrackSelectionFactory) {
- super(context, adaptiveTrackSelectionFactory);
- }
-
- private static boolean formatHasLanguage(final Format format, final String language) {
- return language != null && TextUtils.equals(language, format.language);
- }
-
- public String getPreferredTextLanguage() {
- return preferredTextLanguage;
- }
-
- public void setPreferredTextLanguage(@NonNull final String label) {
- Assertions.checkNotNull(label);
- if (!label.equals(preferredTextLanguage)) {
- preferredTextLanguage = label;
- invalidate();
- }
- }
-
- @Override
- @Nullable
- protected Pair selectTextTrack(
- final TrackGroupArray groups,
- @NonNull final int[][] formatSupport,
- @NonNull final Parameters params,
- @Nullable final String selectedAudioLanguage) {
- TrackGroup selectedGroup = null;
- int selectedTrackIndex = C.INDEX_UNSET;
- TextTrackScore selectedTrackScore = null;
-
- for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) {
- final TrackGroup trackGroup = groups.get(groupIndex);
- @Capabilities final int[] trackFormatSupport = formatSupport[groupIndex];
-
- for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) {
- if (isSupported(trackFormatSupport[trackIndex],
- params.exceedRendererCapabilitiesIfNecessary)) {
- final Format format = trackGroup.getFormat(trackIndex);
- final TextTrackScore trackScore = new TextTrackScore(format, params,
- trackFormatSupport[trackIndex], selectedAudioLanguage);
-
- if (formatHasLanguage(format, preferredTextLanguage)) {
- selectedGroup = trackGroup;
- selectedTrackIndex = trackIndex;
- selectedTrackScore = trackScore;
- break; // found user selected match (perfect!)
-
- } else if (trackScore.isWithinConstraints && (selectedTrackScore == null
- || trackScore.compareTo(selectedTrackScore) > 0)) {
- selectedGroup = trackGroup;
- selectedTrackIndex = trackIndex;
- selectedTrackScore = trackScore;
- }
- }
- }
- }
- return selectedGroup == null ? null
- : Pair.create(new ExoTrackSelection.Definition(selectedGroup, selectedTrackIndex),
- Assertions.checkNotNull(selectedTrackScore));
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java
index f3049d11d..9b13bb3d7 100644
--- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java
+++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java
@@ -11,11 +11,12 @@ import com.google.android.exoplayer2.source.MediaSource;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
+import org.schabi.newpipe.extractor.exceptions.ExtractionException;
+import org.schabi.newpipe.player.mediaitem.MediaItemTag;
import org.schabi.newpipe.player.mediasource.FailedMediaSource;
import org.schabi.newpipe.player.mediasource.LoadedMediaSource;
import org.schabi.newpipe.player.mediasource.ManagedMediaSource;
import org.schabi.newpipe.player.mediasource.ManagedMediaSourcePlaylist;
-import org.schabi.newpipe.player.mediasource.PlaceholderMediaSource;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.player.playqueue.events.MoveEvent;
@@ -195,7 +196,7 @@ public class MediaSourceManager {
//////////////////////////////////////////////////////////////////////////*/
private Subscriber getReactor() {
- return new Subscriber() {
+ return new Subscriber<>() {
@Override
public void onSubscribe(@NonNull final Subscription d) {
playQueueReactor.cancel();
@@ -209,10 +210,12 @@ public class MediaSourceManager {
}
@Override
- public void onError(@NonNull final Throwable e) { }
+ public void onError(@NonNull final Throwable e) {
+ }
@Override
- public void onComplete() { }
+ public void onComplete() {
+ }
};
}
@@ -292,11 +295,11 @@ public class MediaSourceManager {
}
final ManagedMediaSource mediaSource = playlist.get(playQueue.getIndex());
- if (mediaSource == null) {
+ final PlayQueueItem playQueueItem = playQueue.getItem();
+ if (mediaSource == null || playQueueItem == null) {
return false;
}
- final PlayQueueItem playQueueItem = playQueue.getItem();
return mediaSource.isStreamEqual(playQueueItem);
}
@@ -315,7 +318,7 @@ public class MediaSourceManager {
isBlocked.set(true);
}
- private void maybeUnblock() {
+ private boolean maybeUnblock() {
if (DEBUG) {
Log.d(TAG, "maybeUnblock() called.");
}
@@ -323,14 +326,17 @@ public class MediaSourceManager {
if (isBlocked.get()) {
isBlocked.set(false);
playbackListener.onPlaybackUnblock(playlist.getParentMediaSource());
+ return true;
}
+
+ return false;
}
/*//////////////////////////////////////////////////////////////////////////
// Metadata Synchronization
//////////////////////////////////////////////////////////////////////////*/
- private void maybeSync() {
+ private void maybeSync(final boolean wasBlocked) {
if (DEBUG) {
Log.d(TAG, "maybeSync() called.");
}
@@ -340,13 +346,13 @@ public class MediaSourceManager {
return;
}
- playbackListener.onPlaybackSynchronize(currentItem);
+ playbackListener.onPlaybackSynchronize(currentItem, wasBlocked);
}
private synchronized void maybeSynchronizePlayer() {
if (isPlayQueueReady() && isPlaybackReady()) {
- maybeUnblock();
- maybeSync();
+ final boolean isBlockReleased = maybeUnblock();
+ maybeSync(isBlockReleased);
}
}
@@ -417,20 +423,29 @@ public class MediaSourceManager {
private Single getLoadedMediaSource(@NonNull final PlayQueueItem stream) {
return stream.getStream().map(streamInfo -> {
final MediaSource source = playbackListener.sourceOf(stream, streamInfo);
- if (source == null) {
+ if (source == null || !MediaItemTag.from(source.getMediaItem()).isPresent()) {
final String message = "Unable to resolve source from stream info. "
+ "URL: " + stream.getUrl() + ", "
+ "audio count: " + streamInfo.getAudioStreams().size() + ", "
+ "video count: " + streamInfo.getVideoOnlyStreams().size() + ", "
+ streamInfo.getVideoStreams().size();
- return new FailedMediaSource(stream, new MediaSourceResolutionException(message));
+ return (ManagedMediaSource)
+ FailedMediaSource.of(stream, new MediaSourceResolutionException(message));
}
+ final MediaItemTag tag = MediaItemTag.from(source.getMediaItem()).get();
final long expiration = System.currentTimeMillis()
+ ServiceHelper.getCacheExpirationMillis(streamInfo.getServiceId());
- return new LoadedMediaSource(source, stream, expiration);
- }).onErrorReturn(throwable -> new FailedMediaSource(stream,
- new StreamInfoLoadException(throwable)));
+ return new LoadedMediaSource(source, tag, stream, expiration);
+ }).onErrorReturn(throwable -> {
+ if (throwable instanceof ExtractionException) {
+ return FailedMediaSource.of(stream, new StreamInfoLoadException(throwable));
+ }
+ // Non-source related error expected here (e.g. network),
+ // should allow retry shortly after the error.
+ return FailedMediaSource.of(stream, new Exception(throwable),
+ /*allowRetryIn=*/TimeUnit.MILLISECONDS.convert(3, TimeUnit.SECONDS));
+ });
}
private void onMediaSourceReceived(@NonNull final PlayQueueItem item,
@@ -478,23 +493,23 @@ public class MediaSourceManager {
/**
* Checks if the current playing index contains an expired {@link ManagedMediaSource}.
- * If so, the expired source is replaced by a {@link PlaceholderMediaSource} and
+ * If so, the expired source is replaced by a dummy {@link ManagedMediaSource} and
* {@link #loadImmediate()} is called to reload the current item.
*