Merge branch 'sponsorblock' into sponsorblock
This commit is contained in:
commit
83af9d18fa
549 changed files with 8381 additions and 5432 deletions
2
.github/CONTRIBUTING.md
vendored
2
.github/CONTRIBUTING.md
vendored
|
@ -68,7 +68,7 @@ The [checkStyle](https://github.com/checkstyle/checkstyle) plugin verifies that
|
||||||
- Go to `File -> Settings -> Plugins`, search for `checkstyle` and install `CheckStyle-IDEA`.
|
- Go to `File -> Settings -> Plugins`, search for `checkstyle` and install `CheckStyle-IDEA`.
|
||||||
- Go to `File -> Settings -> Tools -> Checkstyle`.
|
- Go to `File -> Settings -> Tools -> Checkstyle`.
|
||||||
- Add NewPipe's configuration file by clicking the `+` in the right toolbar of the "Configuration File" list.
|
- 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.
|
- 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`.
|
- 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.
|
- Activate the configuration file you just added by enabling the checkbox on the left.
|
||||||
|
|
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
|
@ -6,6 +6,7 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
- master
|
- master
|
||||||
|
- release/**
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- 'README.md'
|
- 'README.md'
|
||||||
- 'doc/**'
|
- 'doc/**'
|
||||||
|
@ -31,7 +32,7 @@ jobs:
|
||||||
build-and-test-jvm:
|
build-and-test-jvm:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: gradle/wrapper-validation-action@v1
|
- uses: gradle/wrapper-validation-action@v1
|
||||||
|
|
||||||
- name: create and checkout branch
|
- name: create and checkout branch
|
||||||
|
@ -40,7 +41,7 @@ jobs:
|
||||||
run: git checkout -B ${{ github.head_ref }}
|
run: git checkout -B ${{ github.head_ref }}
|
||||||
|
|
||||||
- name: set up JDK 11
|
- name: set up JDK 11
|
||||||
uses: actions/setup-java@v2
|
uses: actions/setup-java@v3
|
||||||
with:
|
with:
|
||||||
java-version: 11
|
java-version: 11
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
|
@ -50,7 +51,7 @@ jobs:
|
||||||
run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint
|
run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint
|
||||||
|
|
||||||
- name: Upload APK
|
- name: Upload APK
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: app
|
name: app
|
||||||
path: app/build/outputs/apk/debug/*.apk
|
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 19 is min sdk, but throws errors related to desugaring
|
||||||
api-level: [ 21, 29 ]
|
api-level: [ 21, 29 ]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: set up JDK 11
|
- name: set up JDK 11
|
||||||
uses: actions/setup-java@v2
|
uses: actions/setup-java@v3
|
||||||
with:
|
with:
|
||||||
java-version: 11
|
java-version: 11
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
|
@ -82,7 +83,7 @@ jobs:
|
||||||
script: ./gradlew connectedCheck --stacktrace
|
script: ./gradlew connectedCheck --stacktrace
|
||||||
|
|
||||||
- name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553
|
- 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()
|
if: failure()
|
||||||
with:
|
with:
|
||||||
name: android-test-report-api${{ matrix.api-level }}
|
name: android-test-report-api${{ matrix.api-level }}
|
||||||
|
@ -91,19 +92,19 @@ jobs:
|
||||||
sonar:
|
sonar:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||||
|
|
||||||
- name: Set up JDK 11
|
- name: Set up JDK 11
|
||||||
uses: actions/setup-java@v2
|
uses: actions/setup-java@v3
|
||||||
with:
|
with:
|
||||||
java-version: 11 # Sonar requires JDK 11
|
java-version: 11 # Sonar requires JDK 11
|
||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
cache: 'gradle'
|
cache: 'gradle'
|
||||||
|
|
||||||
- name: Cache SonarCloud packages
|
- name: Cache SonarCloud packages
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
path: ~/.sonar/cache
|
path: ~/.sonar/cache
|
||||||
key: ${{ runner.os }}-sonar
|
key: ${{ runner.os }}-sonar
|
||||||
|
|
6
.github/workflows/image-minimizer.yml
vendored
6
.github/workflows/image-minimizer.yml
vendored
|
@ -11,9 +11,9 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- uses: actions/setup-node@v2
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 16
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ jobs:
|
||||||
run: npm i probe-image-size@7.2.3 --ignore-scripts
|
run: npm i probe-image-size@7.2.3 --ignore-scripts
|
||||||
|
|
||||||
- name: Minimize simple images
|
- name: Minimize simple images
|
||||||
uses: actions/github-script@v5
|
uses: actions/github-script@v6
|
||||||
timeout-minutes: 3
|
timeout-minutes: 3
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
|
|
8
LICENSE
8
LICENSE
|
@ -1,7 +1,7 @@
|
||||||
GNU GENERAL PUBLIC LICENSE
|
GNU GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
of this license document, but changing it is not allowed.
|
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.
|
GNU General Public License for more details.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU General Public License
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
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,
|
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.
|
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
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
<http://www.gnu.org/licenses/>.
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program
|
The GNU General Public License does not permit incorporating your program
|
||||||
into proprietary programs. If your program is a subroutine library, you
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
may consider it more useful to permit linking proprietary applications with
|
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
|
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
|
Public License instead of this License. But first, please read
|
||||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
|
|
|
@ -9,15 +9,15 @@ plugins {
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdk 31
|
compileSdk 31
|
||||||
buildToolsVersion '30.0.3'
|
buildToolsVersion '31.0.0'
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "org.polymorphicshade.newpipe"
|
applicationId "org.polymorphicshade.newpipe"
|
||||||
resValue "string", "app_name", "NewPipe SponsorBlock"
|
resValue "string", "app_name", "NewPipe SponsorBlock"
|
||||||
minSdk 19
|
minSdk 19
|
||||||
targetSdk 29
|
targetSdk 29
|
||||||
versionCode 985
|
versionCode 986
|
||||||
versionName "0.22.2"
|
versionName "0.23.0"
|
||||||
|
|
||||||
multiDexEnabled true
|
multiDexEnabled true
|
||||||
|
|
||||||
|
@ -98,13 +98,14 @@ android {
|
||||||
}
|
}
|
||||||
|
|
||||||
ext {
|
ext {
|
||||||
checkstyleVersion = '9.2.1'
|
checkstyleVersion = '10.0'
|
||||||
|
|
||||||
androidxLifecycleVersion = '2.3.1'
|
androidxLifecycleVersion = '2.3.1'
|
||||||
androidxRoomVersion = '2.3.0'
|
androidxRoomVersion = '2.4.2'
|
||||||
|
androidxWorkVersion = '2.7.1'
|
||||||
|
|
||||||
icepickVersion = '3.2.0'
|
icepickVersion = '3.2.0'
|
||||||
exoPlayerVersion = '2.14.2'
|
exoPlayerVersion = '2.17.1'
|
||||||
googleAutoServiceVersion = '1.0.1'
|
googleAutoServiceVersion = '1.0.1'
|
||||||
groupieVersion = '2.10.0'
|
groupieVersion = '2.10.0'
|
||||||
markwonVersion = '4.6.2'
|
markwonVersion = '4.6.2'
|
||||||
|
@ -121,7 +122,7 @@ configurations {
|
||||||
}
|
}
|
||||||
|
|
||||||
checkstyle {
|
checkstyle {
|
||||||
getConfigDirectory().set(rootProject.file("."))
|
getConfigDirectory().set(rootProject.file("checkstyle"))
|
||||||
ignoreFailures false
|
ignoreFailures false
|
||||||
showViolations true
|
showViolations true
|
||||||
toolVersion = checkstyleVersion
|
toolVersion = checkstyleVersion
|
||||||
|
@ -189,11 +190,11 @@ dependencies {
|
||||||
// name and the commit hash with the commit hash of the (pushed) commit you want to test
|
// 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/
|
// This works thanks to JitPack: https://jitpack.io/
|
||||||
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
|
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
|
||||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:b77c72fb8826c3ffca0be5f96b066cca0a07b1c9'
|
implementation 'com.github.TeamNewPipe:NewPipeExtractor:ac1c22d81c65b7b0c5427f4e1989f5256d617f32'
|
||||||
|
|
||||||
/** Checkstyle **/
|
/** Checkstyle **/
|
||||||
checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}"
|
checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}"
|
||||||
ktlint 'com.pinterest:ktlint:0.43.2'
|
ktlint 'com.pinterest:ktlint:0.44.0'
|
||||||
|
|
||||||
/** Kotlin **/
|
/** Kotlin **/
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}"
|
||||||
|
@ -201,16 +202,16 @@ dependencies {
|
||||||
/** AndroidX **/
|
/** AndroidX **/
|
||||||
implementation 'androidx.appcompat:appcompat:1.3.1'
|
implementation 'androidx.appcompat:appcompat:1.3.1'
|
||||||
implementation 'androidx.cardview:cardview:1.0.0'
|
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.core:core-ktx:1.6.0'
|
||||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.3.6'
|
implementation 'androidx.fragment:fragment-ktx:1.3.6'
|
||||||
implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}"
|
implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}"
|
||||||
implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}"
|
implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}"
|
||||||
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0'
|
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
|
||||||
implementation 'androidx.media:media:1.4.3'
|
implementation 'androidx.media:media:1.5.0'
|
||||||
implementation 'androidx.multidex:multidex:2.0.1'
|
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.recyclerview:recyclerview:1.2.1'
|
||||||
implementation "androidx.room:room-runtime:${androidxRoomVersion}"
|
implementation "androidx.room:room-runtime:${androidxRoomVersion}"
|
||||||
implementation "androidx.room:room-rxjava3:${androidxRoomVersion}"
|
implementation "androidx.room:room-rxjava3:${androidxRoomVersion}"
|
||||||
|
@ -220,7 +221,10 @@ dependencies {
|
||||||
// https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01
|
// https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01
|
||||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
||||||
implementation 'androidx.webkit:webkit:1.4.0'
|
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 **/
|
/** Third-party libraries **/
|
||||||
// Instance state boilerplate elimination
|
// Instance state boilerplate elimination
|
||||||
|
@ -246,8 +250,6 @@ dependencies {
|
||||||
implementation "com.github.lisawray.groupie:groupie:${groupieVersion}"
|
implementation "com.github.lisawray.groupie:groupie:${groupieVersion}"
|
||||||
implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}"
|
implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}"
|
||||||
|
|
||||||
// Circular ImageView
|
|
||||||
implementation "de.hdodenhof:circleimageview:3.1.0"
|
|
||||||
// Image loading
|
// Image loading
|
||||||
//noinspection GradleDependency --> 2.8 is the last version, not 2.71828!
|
//noinspection GradleDependency --> 2.8 is the last version, not 2.71828!
|
||||||
implementation "com.squareup.picasso:picasso:2.8"
|
implementation "com.squareup.picasso:picasso:2.8"
|
||||||
|
|
3
app/proguard-rules.pro
vendored
3
app/proguard-rules.pro
vendored
|
@ -51,3 +51,6 @@
|
||||||
private void writeObject(java.io.ObjectOutputStream);
|
private void writeObject(java.io.ObjectOutputStream);
|
||||||
private void readObject(java.io.ObjectInputStream);
|
private void readObject(java.io.ObjectInputStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# for some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml)
|
||||||
|
-keep class org.schabi.newpipe.settings.notifications.** { *; }
|
||||||
|
|
719
app/schemas/org.schabi.newpipe.database.AppDatabase/5.json
Normal file
719
app/schemas/org.schabi.newpipe.database.AppDatabase/5.json
Normal file
|
@ -0,0 +1,719 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 5,
|
||||||
|
"identityHash": "096731b513bb71dd44517639f4a2c1e3",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "subscriptions",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serviceId",
|
||||||
|
"columnName": "service_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "avatarUrl",
|
||||||
|
"columnName": "avatar_url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subscriberCount",
|
||||||
|
"columnName": "subscriber_count",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "notificationMode",
|
||||||
|
"columnName": "notification_mode",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"uid"
|
||||||
|
],
|
||||||
|
"autoGenerate": true
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_subscriptions_service_id_url",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"service_id",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "search_history",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "creationDate",
|
||||||
|
"columnName": "creation_date",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serviceId",
|
||||||
|
"columnName": "service_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "search",
|
||||||
|
"columnName": "search",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"autoGenerate": true
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_search_history_search",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"search"
|
||||||
|
],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "streams",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serviceId",
|
||||||
|
"columnName": "service_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "streamType",
|
||||||
|
"columnName": "stream_type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "duration",
|
||||||
|
"columnName": "duration",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uploader",
|
||||||
|
"columnName": "uploader",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uploaderUrl",
|
||||||
|
"columnName": "uploader_url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "thumbnailUrl",
|
||||||
|
"columnName": "thumbnail_url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "viewCount",
|
||||||
|
"columnName": "view_count",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "textualUploadDate",
|
||||||
|
"columnName": "textual_upload_date",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uploadDate",
|
||||||
|
"columnName": "upload_date",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isUploadDateApproximation",
|
||||||
|
"columnName": "is_upload_date_approximation",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"uid"
|
||||||
|
],
|
||||||
|
"autoGenerate": true
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_streams_service_id_url",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"service_id",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "stream_history",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "streamUid",
|
||||||
|
"columnName": "stream_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "accessDate",
|
||||||
|
"columnName": "access_date",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "repeatCount",
|
||||||
|
"columnName": "repeat_count",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"stream_id",
|
||||||
|
"access_date"
|
||||||
|
],
|
||||||
|
"autoGenerate": false
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_stream_history_stream_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"stream_id"
|
||||||
|
],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "streams",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"stream_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"uid"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "stream_state",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "streamUid",
|
||||||
|
"columnName": "stream_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "progressMillis",
|
||||||
|
"columnName": "progress_time",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"stream_id"
|
||||||
|
],
|
||||||
|
"autoGenerate": false
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "streams",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"stream_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"uid"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "playlists",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "thumbnailUrl",
|
||||||
|
"columnName": "thumbnail_url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"uid"
|
||||||
|
],
|
||||||
|
"autoGenerate": true
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_playlists_name",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`name`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "playlist_stream_join",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "playlistUid",
|
||||||
|
"columnName": "playlist_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "streamUid",
|
||||||
|
"columnName": "stream_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "index",
|
||||||
|
"columnName": "join_index",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"playlist_id",
|
||||||
|
"join_index"
|
||||||
|
],
|
||||||
|
"autoGenerate": false
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_playlist_stream_join_playlist_id_join_index",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"playlist_id",
|
||||||
|
"join_index"
|
||||||
|
],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_playlist_stream_join_stream_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"stream_id"
|
||||||
|
],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "playlists",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"playlist_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"uid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "streams",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"stream_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"uid"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "remote_playlists",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serviceId",
|
||||||
|
"columnName": "service_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "thumbnailUrl",
|
||||||
|
"columnName": "thumbnail_url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uploader",
|
||||||
|
"columnName": "uploader",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "streamCount",
|
||||||
|
"columnName": "stream_count",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"uid"
|
||||||
|
],
|
||||||
|
"autoGenerate": true
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_remote_playlists_name",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_remote_playlists_service_id_url",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"service_id",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "feed",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "streamId",
|
||||||
|
"columnName": "stream_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subscriptionId",
|
||||||
|
"columnName": "subscription_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"stream_id",
|
||||||
|
"subscription_id"
|
||||||
|
],
|
||||||
|
"autoGenerate": false
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_feed_subscription_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"subscription_id"
|
||||||
|
],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "streams",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"stream_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"uid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "subscriptions",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"subscription_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"uid"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "feed_group",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "icon",
|
||||||
|
"columnName": "icon_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sortOrder",
|
||||||
|
"columnName": "sort_order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"uid"
|
||||||
|
],
|
||||||
|
"autoGenerate": true
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_feed_group_sort_order",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sort_order"
|
||||||
|
],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "feed_group_subscription_join",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "feedGroupId",
|
||||||
|
"columnName": "group_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subscriptionId",
|
||||||
|
"columnName": "subscription_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"group_id",
|
||||||
|
"subscription_id"
|
||||||
|
],
|
||||||
|
"autoGenerate": false
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_feed_group_subscription_join_subscription_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"subscription_id"
|
||||||
|
],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "feed_group",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"group_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"uid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "subscriptions",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"subscription_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"uid"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "feed_last_updated",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "subscriptionId",
|
||||||
|
"columnName": "subscription_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastUpdated",
|
||||||
|
"columnName": "last_updated",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"subscription_id"
|
||||||
|
],
|
||||||
|
"autoGenerate": false
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "subscriptions",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"subscription_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"uid"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '096731b513bb71dd44517639f4a2c1e3')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,130 @@
|
||||||
|
package org.schabi.newpipe.database
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.testing.MigrationTestHelper
|
||||||
|
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamType
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class DatabaseMigrationTest {
|
||||||
|
companion object {
|
||||||
|
private const val DEFAULT_SERVICE_ID = 0
|
||||||
|
private const val DEFAULT_URL = "https://www.youtube.com/watch?v=cDphUib5iG4"
|
||||||
|
private const val DEFAULT_TITLE = "Test Title"
|
||||||
|
private val DEFAULT_TYPE = StreamType.VIDEO_STREAM
|
||||||
|
private const val DEFAULT_DURATION = 480L
|
||||||
|
private const val DEFAULT_UPLOADER_NAME = "Uploader Test"
|
||||||
|
private const val DEFAULT_THUMBNAIL = "https://example.com/example.jpg"
|
||||||
|
|
||||||
|
private const val DEFAULT_SECOND_SERVICE_ID = 0
|
||||||
|
private const val DEFAULT_SECOND_URL = "https://www.youtube.com/watch?v=ncQU6iBn5Fc"
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val testHelper = MigrationTestHelper(
|
||||||
|
InstrumentationRegistry.getInstrumentation(),
|
||||||
|
AppDatabase::class.java.canonicalName, FrameworkSQLiteOpenHelperFactory()
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun migrateDatabaseFrom2to3() {
|
||||||
|
val databaseInV2 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_2)
|
||||||
|
|
||||||
|
databaseInV2.run {
|
||||||
|
insert(
|
||||||
|
"streams", SQLiteDatabase.CONFLICT_FAIL,
|
||||||
|
ContentValues().apply {
|
||||||
|
put("service_id", DEFAULT_SERVICE_ID)
|
||||||
|
put("url", DEFAULT_URL)
|
||||||
|
put("title", DEFAULT_TITLE)
|
||||||
|
put("stream_type", DEFAULT_TYPE.name)
|
||||||
|
put("duration", DEFAULT_DURATION)
|
||||||
|
put("uploader", DEFAULT_UPLOADER_NAME)
|
||||||
|
put("thumbnail_url", DEFAULT_THUMBNAIL)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
insert(
|
||||||
|
"streams", SQLiteDatabase.CONFLICT_FAIL,
|
||||||
|
ContentValues().apply {
|
||||||
|
put("service_id", DEFAULT_SECOND_SERVICE_ID)
|
||||||
|
put("url", DEFAULT_SECOND_URL)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
insert(
|
||||||
|
"streams", SQLiteDatabase.CONFLICT_FAIL,
|
||||||
|
ContentValues().apply {
|
||||||
|
put("service_id", DEFAULT_SERVICE_ID)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
testHelper.runMigrationsAndValidate(
|
||||||
|
AppDatabase.DATABASE_NAME, Migrations.DB_VER_3,
|
||||||
|
true, Migrations.MIGRATION_2_3
|
||||||
|
)
|
||||||
|
|
||||||
|
testHelper.runMigrationsAndValidate(
|
||||||
|
AppDatabase.DATABASE_NAME, Migrations.DB_VER_4,
|
||||||
|
true, Migrations.MIGRATION_3_4
|
||||||
|
)
|
||||||
|
|
||||||
|
testHelper.runMigrationsAndValidate(
|
||||||
|
AppDatabase.DATABASE_NAME, Migrations.DB_VER_5,
|
||||||
|
true, Migrations.MIGRATION_4_5
|
||||||
|
)
|
||||||
|
|
||||||
|
val migratedDatabaseV3 = getMigratedDatabase()
|
||||||
|
val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst()
|
||||||
|
|
||||||
|
// Only expect 2, the one with the null url will be ignored
|
||||||
|
assertEquals(2, listFromDB.size)
|
||||||
|
|
||||||
|
val streamFromMigratedDatabase = listFromDB[0]
|
||||||
|
assertEquals(DEFAULT_SERVICE_ID, streamFromMigratedDatabase.serviceId)
|
||||||
|
assertEquals(DEFAULT_URL, streamFromMigratedDatabase.url)
|
||||||
|
assertEquals(DEFAULT_TITLE, streamFromMigratedDatabase.title)
|
||||||
|
assertEquals(DEFAULT_TYPE, streamFromMigratedDatabase.streamType)
|
||||||
|
assertEquals(DEFAULT_DURATION, streamFromMigratedDatabase.duration)
|
||||||
|
assertEquals(DEFAULT_UPLOADER_NAME, streamFromMigratedDatabase.uploader)
|
||||||
|
assertEquals(DEFAULT_THUMBNAIL, streamFromMigratedDatabase.thumbnailUrl)
|
||||||
|
assertNull(streamFromMigratedDatabase.viewCount)
|
||||||
|
assertNull(streamFromMigratedDatabase.textualUploadDate)
|
||||||
|
assertNull(streamFromMigratedDatabase.uploadDate)
|
||||||
|
assertNull(streamFromMigratedDatabase.isUploadDateApproximation)
|
||||||
|
|
||||||
|
val secondStreamFromMigratedDatabase = listFromDB[1]
|
||||||
|
assertEquals(DEFAULT_SECOND_SERVICE_ID, secondStreamFromMigratedDatabase.serviceId)
|
||||||
|
assertEquals(DEFAULT_SECOND_URL, secondStreamFromMigratedDatabase.url)
|
||||||
|
assertEquals("", secondStreamFromMigratedDatabase.title)
|
||||||
|
// Should fallback to VIDEO_STREAM
|
||||||
|
assertEquals(StreamType.VIDEO_STREAM, secondStreamFromMigratedDatabase.streamType)
|
||||||
|
assertEquals(0, secondStreamFromMigratedDatabase.duration)
|
||||||
|
assertEquals("", secondStreamFromMigratedDatabase.uploader)
|
||||||
|
assertEquals("", secondStreamFromMigratedDatabase.thumbnailUrl)
|
||||||
|
assertNull(secondStreamFromMigratedDatabase.viewCount)
|
||||||
|
assertNull(secondStreamFromMigratedDatabase.textualUploadDate)
|
||||||
|
assertNull(secondStreamFromMigratedDatabase.uploadDate)
|
||||||
|
assertNull(secondStreamFromMigratedDatabase.isUploadDateApproximation)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMigratedDatabase(): AppDatabase {
|
||||||
|
val database: AppDatabase = Room.databaseBuilder(
|
||||||
|
ApplicationProvider.getApplicationContext(),
|
||||||
|
AppDatabase::class.java, AppDatabase.DATABASE_NAME
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
testHelper.closeWhenFinished(database)
|
||||||
|
return database
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,188 @@
|
||||||
|
package org.schabi.newpipe.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.SparseArray
|
||||||
|
import android.view.View
|
||||||
|
import android.view.View.GONE
|
||||||
|
import android.view.View.INVISIBLE
|
||||||
|
import android.view.View.VISIBLE
|
||||||
|
import android.widget.Spinner
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.MediumTest
|
||||||
|
import androidx.test.internal.runner.junit4.statement.UiThreadStatement
|
||||||
|
import org.junit.Assert
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.extractor.MediaFormat
|
||||||
|
import org.schabi.newpipe.extractor.stream.AudioStream
|
||||||
|
import org.schabi.newpipe.extractor.stream.Stream
|
||||||
|
import org.schabi.newpipe.extractor.stream.SubtitlesStream
|
||||||
|
import org.schabi.newpipe.extractor.stream.VideoStream
|
||||||
|
|
||||||
|
@MediumTest
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class StreamItemAdapterTest {
|
||||||
|
private lateinit var context: Context
|
||||||
|
private lateinit var spinner: Spinner
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
context = ApplicationProvider.getApplicationContext()
|
||||||
|
UiThreadStatement.runOnUiThread {
|
||||||
|
spinner = Spinner(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun videoStreams_noSecondaryStream() {
|
||||||
|
val adapter = StreamItemAdapter<VideoStream, AudioStream>(
|
||||||
|
context,
|
||||||
|
getVideoStreams(true, true, true, true),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
spinner.adapter = adapter
|
||||||
|
assertIconVisibility(spinner, 0, VISIBLE, VISIBLE)
|
||||||
|
assertIconVisibility(spinner, 1, VISIBLE, VISIBLE)
|
||||||
|
assertIconVisibility(spinner, 2, VISIBLE, VISIBLE)
|
||||||
|
assertIconVisibility(spinner, 3, VISIBLE, VISIBLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun videoStreams_hasSecondaryStream() {
|
||||||
|
val adapter = StreamItemAdapter(
|
||||||
|
context,
|
||||||
|
getVideoStreams(false, true, false, true),
|
||||||
|
getAudioStreams(false, true, false, true)
|
||||||
|
)
|
||||||
|
|
||||||
|
spinner.adapter = adapter
|
||||||
|
assertIconVisibility(spinner, 0, GONE, GONE)
|
||||||
|
assertIconVisibility(spinner, 1, GONE, GONE)
|
||||||
|
assertIconVisibility(spinner, 2, GONE, GONE)
|
||||||
|
assertIconVisibility(spinner, 3, GONE, GONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun videoStreams_Mixed() {
|
||||||
|
val adapter = StreamItemAdapter(
|
||||||
|
context,
|
||||||
|
getVideoStreams(true, true, true, true, true, false, true, true),
|
||||||
|
getAudioStreams(false, true, false, false, false, true, true, true)
|
||||||
|
)
|
||||||
|
|
||||||
|
spinner.adapter = adapter
|
||||||
|
assertIconVisibility(spinner, 0, VISIBLE, VISIBLE)
|
||||||
|
assertIconVisibility(spinner, 1, GONE, INVISIBLE)
|
||||||
|
assertIconVisibility(spinner, 2, VISIBLE, VISIBLE)
|
||||||
|
assertIconVisibility(spinner, 3, VISIBLE, VISIBLE)
|
||||||
|
assertIconVisibility(spinner, 4, VISIBLE, VISIBLE)
|
||||||
|
assertIconVisibility(spinner, 5, GONE, INVISIBLE)
|
||||||
|
assertIconVisibility(spinner, 6, GONE, INVISIBLE)
|
||||||
|
assertIconVisibility(spinner, 7, GONE, INVISIBLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun subtitleStreams_noIcon() {
|
||||||
|
val adapter = StreamItemAdapter<SubtitlesStream, Stream>(
|
||||||
|
context,
|
||||||
|
StreamItemAdapter.StreamSizeWrapper(
|
||||||
|
(0 until 5).map {
|
||||||
|
SubtitlesStream(MediaFormat.SRT, "pt-BR", "https://example.com", false)
|
||||||
|
},
|
||||||
|
context
|
||||||
|
),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
spinner.adapter = adapter
|
||||||
|
for (i in 0 until spinner.count) {
|
||||||
|
assertIconVisibility(spinner, i, GONE, GONE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun audioStreams_noIcon() {
|
||||||
|
val adapter = StreamItemAdapter<AudioStream, Stream>(
|
||||||
|
context,
|
||||||
|
StreamItemAdapter.StreamSizeWrapper(
|
||||||
|
(0 until 5).map { AudioStream("https://example.com/$it", MediaFormat.OPUS, 192) },
|
||||||
|
context
|
||||||
|
),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
spinner.adapter = adapter
|
||||||
|
for (i in 0 until spinner.count) {
|
||||||
|
assertIconVisibility(spinner, i, GONE, GONE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return a list of video streams, in which their video only property mirrors the provided
|
||||||
|
* [videoOnly] vararg.
|
||||||
|
*/
|
||||||
|
private fun getVideoStreams(vararg videoOnly: Boolean) =
|
||||||
|
StreamItemAdapter.StreamSizeWrapper(
|
||||||
|
videoOnly.map {
|
||||||
|
VideoStream("https://example.com", MediaFormat.MPEG_4, "720p", it)
|
||||||
|
},
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return a list of audio streams, containing valid and null elements mirroring the provided
|
||||||
|
* [shouldBeValid] vararg.
|
||||||
|
*/
|
||||||
|
private fun getAudioStreams(vararg shouldBeValid: Boolean) =
|
||||||
|
getSecondaryStreamsFromList(
|
||||||
|
shouldBeValid.map {
|
||||||
|
if (it) AudioStream("https://example.com", MediaFormat.OPUS, 192)
|
||||||
|
else null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the item at [position] in the [spinner] has the correct icon visibility when
|
||||||
|
* it is shown in normal mode (selected) and in dropdown mode (user is choosing one of a list).
|
||||||
|
*/
|
||||||
|
private fun assertIconVisibility(
|
||||||
|
spinner: Spinner,
|
||||||
|
position: Int,
|
||||||
|
normalVisibility: Int,
|
||||||
|
dropDownVisibility: Int
|
||||||
|
) {
|
||||||
|
spinner.setSelection(position)
|
||||||
|
spinner.adapter.getView(position, null, spinner).run {
|
||||||
|
Assert.assertEquals(
|
||||||
|
"normal visibility (pos=[$position]) is not correct",
|
||||||
|
findViewById<View>(R.id.wo_sound_icon).visibility,
|
||||||
|
normalVisibility,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
spinner.adapter.getDropDownView(position, null, spinner).run {
|
||||||
|
Assert.assertEquals(
|
||||||
|
"drop down visibility (pos=[$position]) is not correct",
|
||||||
|
findViewById<View>(R.id.wo_sound_icon).visibility,
|
||||||
|
dropDownVisibility
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function that builds a secondary stream list.
|
||||||
|
*/
|
||||||
|
private fun <T : Stream> getSecondaryStreamsFromList(streams: List<T?>) =
|
||||||
|
SparseArray<SecondaryStreamHelper<T>?>(streams.size).apply {
|
||||||
|
streams.forEachIndexed { index, stream ->
|
||||||
|
val secondaryStreamHelper: SecondaryStreamHelper<T>? = stream?.let {
|
||||||
|
SecondaryStreamHelper(
|
||||||
|
StreamItemAdapter.StreamSizeWrapper(streams, context),
|
||||||
|
it
|
||||||
|
)
|
||||||
|
}
|
||||||
|
put(index, secondaryStreamHelper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -386,9 +386,6 @@
|
||||||
<service
|
<service
|
||||||
android:name=".RouterActivity$FetcherService"
|
android:name=".RouterActivity$FetcherService"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
<service
|
|
||||||
android:name=".CheckForNewAppVersion"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<!-- opting out of sending metrics to Google in Android System WebView -->
|
<!-- opting out of sending metrics to Google in Android System WebView -->
|
||||||
<meta-data android:name="android.webkit.WebView.MetricsOptOut" android:value="true" />
|
<meta-data android:name="android.webkit.WebView.MetricsOptOut" android:value="true" />
|
||||||
|
|
|
@ -27,7 +27,7 @@ import org.schabi.newpipe.util.StateSaver;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InterruptedIOException;
|
import java.io.InterruptedIOException;
|
||||||
import java.net.SocketException;
|
import java.net.SocketException;
|
||||||
import java.util.Arrays;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
@ -213,37 +213,44 @@ public class App extends MultiDexApplication {
|
||||||
private void initNotificationChannels() {
|
private void initNotificationChannels() {
|
||||||
// Keep the importance below DEFAULT to avoid making noise on every notification update for
|
// Keep the importance below DEFAULT to avoid making noise on every notification update for
|
||||||
// the main and update channels
|
// the main and update channels
|
||||||
final NotificationChannelCompat mainChannel = new NotificationChannelCompat
|
final List<NotificationChannelCompat> notificationChannelCompats = new ArrayList<>();
|
||||||
|
notificationChannelCompats.add(new NotificationChannelCompat
|
||||||
.Builder(getString(R.string.notification_channel_id),
|
.Builder(getString(R.string.notification_channel_id),
|
||||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||||
.setName(getString(R.string.notification_channel_name))
|
.setName(getString(R.string.notification_channel_name))
|
||||||
.setDescription(getString(R.string.notification_channel_description))
|
.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),
|
.Builder(getString(R.string.app_update_notification_channel_id),
|
||||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||||
.setName(getString(R.string.app_update_notification_channel_name))
|
.setName(getString(R.string.app_update_notification_channel_name))
|
||||||
.setDescription(getString(R.string.app_update_notification_channel_description))
|
.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),
|
.Builder(getString(R.string.hash_channel_id),
|
||||||
NotificationManagerCompat.IMPORTANCE_HIGH)
|
NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||||
.setName(getString(R.string.hash_channel_name))
|
.setName(getString(R.string.hash_channel_name))
|
||||||
.setDescription(getString(R.string.hash_channel_description))
|
.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),
|
.Builder(getString(R.string.error_report_channel_id),
|
||||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||||
.setName(getString(R.string.error_report_channel_name))
|
.setName(getString(R.string.error_report_channel_name))
|
||||||
.setDescription(getString(R.string.error_report_channel_description))
|
.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);
|
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
|
||||||
notificationManager.createNotificationChannelsCompat(Arrays.asList(mainChannel,
|
notificationManager.createNotificationChannelsCompat(notificationChannelCompats);
|
||||||
appUpdateChannel, hashChannel, errorReportChannel));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected boolean isDisposedRxExceptionsReported() {
|
protected boolean isDisposedRxExceptionsReported() {
|
||||||
|
|
|
@ -1,147 +0,0 @@
|
||||||
package org.schabi.newpipe;
|
|
||||||
|
|
||||||
import android.app.Application;
|
|
||||||
import android.app.PendingIntent;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.SharedPreferences;
|
|
||||||
import android.net.ConnectivityManager;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.core.app.NotificationCompat;
|
|
||||||
import androidx.core.app.NotificationManagerCompat;
|
|
||||||
import androidx.core.content.ContextCompat;
|
|
||||||
import androidx.preference.PreferenceManager;
|
|
||||||
|
|
||||||
import com.grack.nanojson.JsonObject;
|
|
||||||
import com.grack.nanojson.JsonParser;
|
|
||||||
import com.grack.nanojson.JsonParserException;
|
|
||||||
|
|
||||||
import org.schabi.newpipe.util.Version;
|
|
||||||
|
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
|
||||||
import io.reactivex.rxjava3.core.Maybe;
|
|
||||||
import io.reactivex.rxjava3.disposables.Disposable;
|
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
|
||||||
|
|
||||||
public final class CheckForNewAppVersion {
|
|
||||||
private CheckForNewAppVersion() {
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final boolean DEBUG = MainActivity.DEBUG;
|
|
||||||
private static final String TAG = CheckForNewAppVersion.class.getSimpleName();
|
|
||||||
private static final String API_URL =
|
|
||||||
"https://api.github.com/repos/polymorphicshade/NewPipe/releases/latest";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Method to compare the current and latest available app version.
|
|
||||||
* If a newer version is available, we show the update notification.
|
|
||||||
*
|
|
||||||
* @param application The application
|
|
||||||
* @param versionName Name of new version
|
|
||||||
* @param apkLocationUrl Url with the new apk
|
|
||||||
*/
|
|
||||||
private static void compareAppVersionAndShowNotification(@NonNull final Application application,
|
|
||||||
final String versionName,
|
|
||||||
final String apkLocationUrl) {
|
|
||||||
final Version sourceVersion = Version.fromString(BuildConfig.VERSION_NAME);
|
|
||||||
final Version targetVersion = Version.fromString(versionName);
|
|
||||||
|
|
||||||
// abort if source version is the same or newer than target version
|
|
||||||
if (sourceVersion.compareTo(targetVersion) >= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkLocationUrl));
|
|
||||||
final PendingIntent pendingIntent
|
|
||||||
= PendingIntent.getActivity(application, 0, intent, 0);
|
|
||||||
|
|
||||||
final String channelId = application
|
|
||||||
.getString(R.string.app_update_notification_channel_id);
|
|
||||||
final NotificationCompat.Builder notificationBuilder
|
|
||||||
= new NotificationCompat.Builder(application, channelId)
|
|
||||||
.setSmallIcon(R.drawable.ic_newpipe_update)
|
|
||||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setContentTitle(application
|
|
||||||
.getString(R.string.app_update_notification_content_title))
|
|
||||||
.setContentText(application
|
|
||||||
.getString(R.string.app_update_notification_content_text)
|
|
||||||
+ " " + versionName);
|
|
||||||
|
|
||||||
final NotificationManagerCompat notificationManager
|
|
||||||
= NotificationManagerCompat.from(application);
|
|
||||||
notificationManager.notify(2000, notificationBuilder.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static boolean isReleaseApk(@NonNull final App app) {
|
|
||||||
// TODO: hope this isn't gonna be an issue
|
|
||||||
//return getCertificateSHA1Fingerprint(app).equals(RELEASE_CERT_PUBLIC_KEY_SHA1);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isConnected(@NonNull final App app) {
|
|
||||||
final ConnectivityManager connectivityManager =
|
|
||||||
ContextCompat.getSystemService(app, ConnectivityManager.class);
|
|
||||||
return connectivityManager != null && connectivityManager.getActiveNetworkInfo() != null
|
|
||||||
&& connectivityManager.getActiveNetworkInfo().isConnected();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
public static Disposable checkNewVersion() {
|
|
||||||
final App app = App.getApp();
|
|
||||||
|
|
||||||
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
|
|
||||||
|
|
||||||
// Check if user has enabled/disabled update checking
|
|
||||||
// and if the current apk is a github one or not.
|
|
||||||
if (!prefs.getBoolean(app.getString(R.string.update_app_key), true)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Maybe
|
|
||||||
.fromCallable(() -> {
|
|
||||||
if (!isConnected(app)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make a network request to get latest NewPipe data.
|
|
||||||
return DownloaderImpl.getInstance().get(API_URL).responseBody();
|
|
||||||
})
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(
|
|
||||||
response -> {
|
|
||||||
// Parse the json from the response.
|
|
||||||
try {
|
|
||||||
// assuming the first result is the latest one
|
|
||||||
final JsonObject jObj = JsonParser.object().from(response);
|
|
||||||
|
|
||||||
final String versionName = jObj.getString("tag_name");
|
|
||||||
|
|
||||||
final String apkLocationUrl = jObj
|
|
||||||
.getArray("assets")
|
|
||||||
.getObject(0)
|
|
||||||
.getString("browser_download_url");
|
|
||||||
|
|
||||||
compareAppVersionAndShowNotification(app, versionName,
|
|
||||||
apkLocationUrl);
|
|
||||||
} catch (final JsonParserException e) {
|
|
||||||
// connectivity problems, do not alarm user and fail silently
|
|
||||||
if (DEBUG) {
|
|
||||||
Log.w(TAG, "Could not get Github API: invalid json", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
e -> {
|
|
||||||
// connectivity problems, do not alarm user and fail silently
|
|
||||||
if (DEBUG) {
|
|
||||||
Log.w(TAG, "Could not get Github API: network problem", e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -29,7 +29,7 @@ import org.schabi.newpipe.util.VideoSegment;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class LocalPlayerActivity extends AppCompatActivity implements Player.EventListener,
|
public class LocalPlayerActivity extends AppCompatActivity implements Player.Listener,
|
||||||
LocalPlayerListener, PlaybackParameterDialog.Callback {
|
LocalPlayerListener, PlaybackParameterDialog.Callback {
|
||||||
private LocalPlayer localPlayer;
|
private LocalPlayer localPlayer;
|
||||||
private PlayerView playerView;
|
private PlayerView playerView;
|
||||||
|
|
|
@ -20,7 +20,6 @@
|
||||||
|
|
||||||
package org.schabi.newpipe;
|
package org.schabi.newpipe;
|
||||||
|
|
||||||
import static org.schabi.newpipe.CheckForNewAppVersion.checkNewVersion;
|
|
||||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||||
|
|
||||||
import android.content.BroadcastReceiver;
|
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.MainFragment;
|
||||||
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
|
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
|
||||||
import org.schabi.newpipe.fragments.list.search.SearchFragment;
|
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.Player;
|
||||||
import org.schabi.newpipe.player.event.OnKeyDownListener;
|
import org.schabi.newpipe.player.event.OnKeyDownListener;
|
||||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||||
|
@ -159,20 +159,28 @@ public class MainActivity extends AppCompatActivity {
|
||||||
} catch (final Exception e) {
|
} catch (final Exception e) {
|
||||||
ErrorUtil.showUiErrorSnackbar(this, "Setting up drawer", e);
|
ErrorUtil.showUiErrorSnackbar(this, "Setting up drawer", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DeviceUtils.isTv(this)) {
|
if (DeviceUtils.isTv(this)) {
|
||||||
FocusOverlayView.setupFocusObserver(this);
|
FocusOverlayView.setupFocusObserver(this);
|
||||||
}
|
}
|
||||||
openMiniPlayerUponPlayerStarted();
|
openMiniPlayerUponPlayerStarted();
|
||||||
|
|
||||||
|
// Schedule worker for checking for new streams and creating corresponding notifications
|
||||||
|
// if this is enabled by the user.
|
||||||
|
NotificationWorker.initialize(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onPostCreate(final Bundle savedInstanceState) {
|
protected void onPostCreate(final Bundle savedInstanceState) {
|
||||||
super.onPostCreate(savedInstanceState);
|
super.onPostCreate(savedInstanceState);
|
||||||
// Start the service which is checking all conditions
|
|
||||||
// and eventually searching for a new version.
|
final App app = App.getApp();
|
||||||
// The service searching for a new NewPipe version must not be started in background.
|
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
|
||||||
checkNewVersion();
|
|
||||||
|
if (prefs.getBoolean(app.getString(R.string.update_app_key), true)) {
|
||||||
|
// Start the worker which is checking all conditions
|
||||||
|
// and eventually searching for a new version.
|
||||||
|
NewVersionWorker.enqueueNewVersionCheckingWork(app);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setupDrawer() throws ExtractionException {
|
private void setupDrawer() throws ExtractionException {
|
||||||
|
@ -221,7 +229,7 @@ public class MainActivity extends AppCompatActivity {
|
||||||
drawerLayoutBinding.navigation.getMenu()
|
drawerLayoutBinding.navigation.getMenu()
|
||||||
.add(R.id.menu_tabs_group, kioskId, 0, KioskTranslator
|
.add(R.id.menu_tabs_group, kioskId, 0, KioskTranslator
|
||||||
.getTranslatedKioskName(ks, this))
|
.getTranslatedKioskName(ks, this))
|
||||||
.setIcon(KioskTranslator.getKioskIcon(ks, this));
|
.setIcon(KioskTranslator.getKioskIcon(ks));
|
||||||
kioskId++;
|
kioskId++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -713,7 +721,7 @@ public class MainActivity extends AppCompatActivity {
|
||||||
if (toggle != null) {
|
if (toggle != null) {
|
||||||
toggle.syncState();
|
toggle.syncState();
|
||||||
toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> mainBinding.getRoot()
|
toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> mainBinding.getRoot()
|
||||||
.openDrawer(GravityCompat.START));
|
.open());
|
||||||
mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED);
|
mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -1,5 +1,11 @@
|
||||||
package org.schabi.newpipe;
|
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.content.Context;
|
||||||
import android.database.Cursor;
|
import android.database.Cursor;
|
||||||
|
|
||||||
|
@ -8,11 +14,6 @@ import androidx.room.Room;
|
||||||
|
|
||||||
import org.schabi.newpipe.database.AppDatabase;
|
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 {
|
public final class NewPipeDatabase {
|
||||||
private static volatile AppDatabase databaseInstance;
|
private static volatile AppDatabase databaseInstance;
|
||||||
|
|
||||||
|
@ -23,7 +24,7 @@ public final class NewPipeDatabase {
|
||||||
private static AppDatabase getDatabase(final Context context) {
|
private static AppDatabase getDatabase(final Context context) {
|
||||||
return Room
|
return Room
|
||||||
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
|
.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();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
package org.schabi.newpipe
|
|
||||||
|
|
||||||
import java.time.Instant
|
|
||||||
import java.time.ZonedDateTime
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
|
|
||||||
class NewVersionManager {
|
|
||||||
|
|
||||||
fun isExpired(expiry: Long): Boolean {
|
|
||||||
return Instant.ofEpochSecond(expiry).isBefore(Instant.now())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Coerce expiry date time in between 6 hours and 72 hours from now
|
|
||||||
*
|
|
||||||
* @return Epoch second of expiry date time
|
|
||||||
*/
|
|
||||||
fun coerceExpiry(expiryString: String?): Long {
|
|
||||||
val now = ZonedDateTime.now()
|
|
||||||
return expiryString?.let {
|
|
||||||
|
|
||||||
var expiry = ZonedDateTime.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(expiryString))
|
|
||||||
expiry = maxOf(expiry, now.plusHours(6))
|
|
||||||
expiry = minOf(expiry, now.plusHours(72))
|
|
||||||
expiry.toEpochSecond()
|
|
||||||
} ?: now.plusHours(6).toEpochSecond()
|
|
||||||
}
|
|
||||||
}
|
|
160
app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt
Normal file
160
app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
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.Version
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class NewVersionWorker(
|
||||||
|
context: Context,
|
||||||
|
workerParams: WorkerParameters
|
||||||
|
) : Worker(context, workerParams) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method to compare the current and latest available app version.
|
||||||
|
* If a newer version is available, we show the update notification.
|
||||||
|
*
|
||||||
|
* @param versionName Name of new version
|
||||||
|
* @param apkLocationUrl Url with the new apk
|
||||||
|
*/
|
||||||
|
private fun compareAppVersionAndShowNotification(
|
||||||
|
versionName: String,
|
||||||
|
apkLocationUrl: String?
|
||||||
|
) {
|
||||||
|
val sourceVersion = Version.fromString(BuildConfig.VERSION_NAME)
|
||||||
|
val targetVersion = Version.fromString(versionName)
|
||||||
|
|
||||||
|
// abort if source version is the same or newer than target version
|
||||||
|
if (sourceVersion >= targetVersion) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val app = App.getApp()
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, apkLocationUrl?.toUri())
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(app, 0, intent, 0)
|
||||||
|
val channelId = app.getString(R.string.app_update_notification_channel_id)
|
||||||
|
val notificationBuilder = NotificationCompat.Builder(app, channelId)
|
||||||
|
.setSmallIcon(R.drawable.ic_newpipe_update)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentTitle(app.getString(R.string.app_update_notification_content_title))
|
||||||
|
.setContentText(
|
||||||
|
app.getString(R.string.app_update_notification_content_text) +
|
||||||
|
" " + versionName
|
||||||
|
)
|
||||||
|
val notificationManager = NotificationManagerCompat.from(app)
|
||||||
|
notificationManager.notify(2000, notificationBuilder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class, ReCaptchaException::class)
|
||||||
|
private fun checkNewVersion() {
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||||
|
// Check if the last request has happened a certain time ago
|
||||||
|
// to reduce the number of API requests.
|
||||||
|
val expiry = prefs.getLong(applicationContext.getString(R.string.update_expiry_key), 0)
|
||||||
|
if (!isLastUpdateCheckExpired(expiry)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a network request to get latest NewPipe data.
|
||||||
|
val response = DownloaderImpl.getInstance().get(API_URL)
|
||||||
|
handleResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleResponse(response: Response) {
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||||
|
try {
|
||||||
|
// Store a timestamp which needs to be exceeded,
|
||||||
|
// before a new request to the API is made.
|
||||||
|
val newExpiry = coerceUpdateCheckExpiry(response.getHeader("expires"))
|
||||||
|
prefs.edit {
|
||||||
|
putLong(applicationContext.getString(R.string.update_expiry_key), newExpiry)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.w(TAG, "Could not extract and save new expiry date", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the json from the response.
|
||||||
|
try {
|
||||||
|
val jObj = JsonParser.`object`().from(response.responseBody())
|
||||||
|
val versionName = jObj.getString("tag_name")
|
||||||
|
val apkLocationUrl = jObj
|
||||||
|
.getArray("assets")
|
||||||
|
.getObject(0)
|
||||||
|
.getString("browser_download_url")
|
||||||
|
compareAppVersionAndShowNotification(versionName, apkLocationUrl)
|
||||||
|
} catch (e: JsonParserException) {
|
||||||
|
// Most likely something is wrong in data received from API_URL.
|
||||||
|
// Do not alarm user and fail silently.
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.w(TAG, "Could not get Github API: invalid json", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun doWork(): Result {
|
||||||
|
try {
|
||||||
|
checkNewVersion()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e)
|
||||||
|
return Result.failure()
|
||||||
|
} catch (e: ReCaptchaException) {
|
||||||
|
Log.e(TAG, "ReCaptchaException should never happen here.", e)
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val DEBUG = MainActivity.DEBUG
|
||||||
|
private val TAG = NewVersionWorker::class.java.simpleName
|
||||||
|
private const val API_URL =
|
||||||
|
"https://api.github.com/repos/polymorphicshade/NewPipe/releases/latest"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a new worker which
|
||||||
|
* checks if all conditions for performing a version check are met,
|
||||||
|
* fetches the API endpoint [.NEWPIPE_API_URL] containing info
|
||||||
|
* about the latest NewPipe version
|
||||||
|
* and displays a notification about ana available update.
|
||||||
|
* <br></br>
|
||||||
|
* Following conditions need to be met, before data is request from the server:
|
||||||
|
*
|
||||||
|
* * The app is signed with the correct signing key (by TeamNewPipe / schabi).
|
||||||
|
* If the signing key differs from the one used upstream, the update cannot be installed.
|
||||||
|
* * The user enabled searching for and notifying about updates in the settings.
|
||||||
|
* * The app did not recently check for updates.
|
||||||
|
* We do not want to make unnecessary connections and DOS our servers.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun enqueueNewVersionCheckingWork(context: Context) {
|
||||||
|
val workRequest: WorkRequest =
|
||||||
|
OneTimeWorkRequest.Builder(NewVersionWorker::class.java).build()
|
||||||
|
WorkManager.getInstance(context).enqueue(workRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,7 +14,7 @@ import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||||
import org.schabi.newpipe.util.NavigationHelper;
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
import org.schabi.newpipe.util.SaveUploaderUrlHelper;
|
import org.schabi.newpipe.util.SparseItemUtil;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
|
||||||
|
@ -62,7 +62,8 @@ public final class QueueItemMenuUtil {
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
case R.id.menu_item_channel_details:
|
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.
|
// An intent must be used here.
|
||||||
// Opening with FragmentManager transactions is not working,
|
// Opening with FragmentManager transactions is not working,
|
||||||
// as PlayQueueActivity doesn't use fragments.
|
// as PlayQueueActivity doesn't use fragments.
|
||||||
|
|
|
@ -633,7 +633,7 @@ public class RouterActivity extends AppCompatActivity {
|
||||||
.subscribe(result -> {
|
.subscribe(result -> {
|
||||||
final List<VideoStream> sortedVideoStreams = ListHelper
|
final List<VideoStream> sortedVideoStreams = ListHelper
|
||||||
.getSortedStreamVideosList(this, result.getVideoStreams(),
|
.getSortedStreamVideosList(this, result.getVideoStreams(),
|
||||||
result.getVideoOnlyStreams(), false);
|
result.getVideoOnlyStreams(), false, false);
|
||||||
final int selectedVideoStreamIndex = ListHelper
|
final int selectedVideoStreamIndex = ListHelper
|
||||||
.getDefaultResolutionIndex(this, sortedVideoStreams);
|
.getDefaultResolutionIndex(this, sortedVideoStreams);
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,6 @@ import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||||
import com.google.android.material.tabs.TabLayout
|
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
import org.schabi.newpipe.BuildConfig
|
import org.schabi.newpipe.BuildConfig
|
||||||
import org.schabi.newpipe.R
|
import org.schabi.newpipe.R
|
||||||
|
@ -21,30 +20,28 @@ import org.schabi.newpipe.util.ThemeHelper
|
||||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||||
|
|
||||||
class AboutActivity : AppCompatActivity() {
|
class AboutActivity : AppCompatActivity() {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
Localization.assureCorrectAppLanguage(this)
|
Localization.assureCorrectAppLanguage(this)
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
ThemeHelper.setTheme(this)
|
ThemeHelper.setTheme(this)
|
||||||
title = getString(R.string.title_activity_about)
|
title = getString(R.string.title_activity_about)
|
||||||
|
|
||||||
val aboutBinding = ActivityAboutBinding.inflate(layoutInflater)
|
val aboutBinding = ActivityAboutBinding.inflate(layoutInflater)
|
||||||
setContentView(aboutBinding.root)
|
setContentView(aboutBinding.root)
|
||||||
setSupportActionBar(aboutBinding.aboutToolbar)
|
setSupportActionBar(aboutBinding.aboutToolbar)
|
||||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
|
||||||
// Create the adapter that will return a fragment for each of the three
|
// Create the adapter that will return a fragment for each of the three
|
||||||
// primary sections of the activity.
|
// primary sections of the activity.
|
||||||
val mAboutStateAdapter = AboutStateAdapter(this)
|
val mAboutStateAdapter = AboutStateAdapter(this)
|
||||||
|
|
||||||
// Set up the ViewPager with the sections adapter.
|
// Set up the ViewPager with the sections adapter.
|
||||||
aboutBinding.aboutViewPager2.adapter = mAboutStateAdapter
|
aboutBinding.aboutViewPager2.adapter = mAboutStateAdapter
|
||||||
TabLayoutMediator(
|
TabLayoutMediator(
|
||||||
aboutBinding.aboutTabLayout,
|
aboutBinding.aboutTabLayout,
|
||||||
aboutBinding.aboutViewPager2
|
aboutBinding.aboutViewPager2
|
||||||
) { tab: TabLayout.Tab, position: Int ->
|
) { tab, position ->
|
||||||
when (position) {
|
tab.setText(mAboutStateAdapter.getPageTitle(position))
|
||||||
POS_ABOUT -> tab.setText(R.string.tab_about)
|
|
||||||
POS_LICENSE -> tab.setText(R.string.tab_licenses)
|
|
||||||
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
|
|
||||||
}
|
|
||||||
}.attach()
|
}.attach()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,13 +72,14 @@ class AboutActivity : AppCompatActivity() {
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?
|
savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
val aboutBinding = FragmentAboutBinding.inflate(inflater, container, false)
|
FragmentAboutBinding.inflate(inflater, container, false).apply {
|
||||||
aboutBinding.aboutAppVersion.text = BuildConfig.VERSION_NAME
|
aboutAppVersion.text = BuildConfig.VERSION_NAME
|
||||||
aboutBinding.aboutGithubLink.openLink(R.string.github_url)
|
aboutGithubLink.openLink(R.string.github_url)
|
||||||
aboutBinding.aboutDonationLink.openLink(R.string.donation_url)
|
aboutDonationLink.openLink(R.string.donation_url)
|
||||||
aboutBinding.aboutWebsiteLink.openLink(R.string.website_url)
|
aboutWebsiteLink.openLink(R.string.website_url)
|
||||||
aboutBinding.aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url)
|
aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url)
|
||||||
return aboutBinding.root
|
return root
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -90,17 +88,29 @@ class AboutActivity : AppCompatActivity() {
|
||||||
* one of the sections/tabs/pages.
|
* one of the sections/tabs/pages.
|
||||||
*/
|
*/
|
||||||
private class AboutStateAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
|
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 {
|
override fun createFragment(position: Int): Fragment {
|
||||||
return when (position) {
|
return when (position) {
|
||||||
POS_ABOUT -> AboutFragment()
|
posAbout -> AboutFragment()
|
||||||
POS_LICENSE -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS)
|
posLicense -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS)
|
||||||
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
|
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount(): Int {
|
override fun getItemCount(): Int {
|
||||||
// Show 2 total pages.
|
// 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",
|
"AndroidX", "2005 - 2011", "The Android Open Source Project",
|
||||||
"https://developer.android.com/jetpack", StandardLicenses.APACHE2
|
"https://developer.android.com/jetpack", StandardLicenses.APACHE2
|
||||||
),
|
),
|
||||||
SoftwareComponent(
|
|
||||||
"CircleImageView", "2014 - 2020", "Henning Dodenhof",
|
|
||||||
"https://github.com/hdodenhof/CircleImageView", StandardLicenses.APACHE2
|
|
||||||
),
|
|
||||||
SoftwareComponent(
|
SoftwareComponent(
|
||||||
"ExoPlayer", "2014 - 2020", "Google, Inc.",
|
"ExoPlayer", "2014 - 2020", "Google, Inc.",
|
||||||
"https://github.com/google/ExoPlayer", StandardLicenses.APACHE2
|
"https://github.com/google/ExoPlayer", StandardLicenses.APACHE2
|
||||||
|
@ -191,8 +197,5 @@ class AboutActivity : AppCompatActivity() {
|
||||||
"https://github.com/ByteHamster/SearchPreference", StandardLicenses.MIT
|
"https://github.com/ByteHamster/SearchPreference", StandardLicenses.MIT
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
private const val POS_ABOUT = 0
|
|
||||||
private const val POS_LICENSE = 1
|
|
||||||
private const val TOTAL_COUNT = 2
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -87,60 +87,50 @@ object LicenseFragmentHelper {
|
||||||
return context.getString(color).substring(3)
|
return context.getString(color).substring(3)
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun showLicense(context: Context?, license: License): Disposable {
|
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) {
|
return if (context == null) {
|
||||||
Disposable.empty()
|
Disposable.empty()
|
||||||
} else {
|
} else {
|
||||||
Observable.fromCallable { getFormattedLicense(context, license) }
|
Observable.fromCallable { getFormattedLicense(context, license) }
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe { formattedLicense: String ->
|
.subscribe { formattedLicense ->
|
||||||
val webViewData = Base64.encodeToString(
|
val webViewData = Base64.encodeToString(
|
||||||
formattedLicense
|
formattedLicense.toByteArray(StandardCharsets.UTF_8), Base64.NO_PADDING
|
||||||
.toByteArray(StandardCharsets.UTF_8),
|
|
||||||
Base64.NO_PADDING
|
|
||||||
)
|
)
|
||||||
val webView = WebView(context)
|
val webView = WebView(context)
|
||||||
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
|
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
|
||||||
val alert = AlertDialog.Builder(context)
|
|
||||||
alert.setTitle(license.name)
|
AlertDialog.Builder(context).apply {
|
||||||
alert.setView(webView)
|
setTitle(license.name)
|
||||||
Localization.assureCorrectAppLanguage(context)
|
setView(webView)
|
||||||
alert.setNegativeButton(
|
Localization.assureCorrectAppLanguage(context)
|
||||||
context.getString(R.string.ok)
|
block(this)
|
||||||
) { dialog, _ -> dialog.dismiss() }
|
show()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
alert.show()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
package org.schabi.newpipe.database;
|
package org.schabi.newpipe.database;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.database.Migrations.DB_VER_5;
|
||||||
|
|
||||||
import androidx.room.Database;
|
import androidx.room.Database;
|
||||||
import androidx.room.RoomDatabase;
|
import androidx.room.RoomDatabase;
|
||||||
import androidx.room.TypeConverters;
|
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.SubscriptionDAO;
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||||
|
|
||||||
import static org.schabi.newpipe.database.Migrations.DB_VER_4;
|
|
||||||
|
|
||||||
@TypeConverters({Converters.class})
|
@TypeConverters({Converters.class})
|
||||||
@Database(
|
@Database(
|
||||||
entities = {
|
entities = {
|
||||||
|
@ -38,7 +38,7 @@ import static org.schabi.newpipe.database.Migrations.DB_VER_4;
|
||||||
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
|
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
|
||||||
FeedLastUpdatedEntity.class
|
FeedLastUpdatedEntity.class
|
||||||
},
|
},
|
||||||
version = DB_VER_4
|
version = DB_VER_5
|
||||||
)
|
)
|
||||||
public abstract class AppDatabase extends RoomDatabase {
|
public abstract class AppDatabase extends RoomDatabase {
|
||||||
public static final String DATABASE_NAME = "newpipe.db";
|
public static final String DATABASE_NAME = "newpipe.db";
|
||||||
|
|
|
@ -22,6 +22,7 @@ public final class Migrations {
|
||||||
public static final int DB_VER_2 = 2;
|
public static final int DB_VER_2 = 2;
|
||||||
public static final int DB_VER_3 = 3;
|
public static final int DB_VER_3 = 3;
|
||||||
public static final int DB_VER_4 = 4;
|
public static final int DB_VER_4 = 4;
|
||||||
|
public static final int DB_VER_5 = 5;
|
||||||
|
|
||||||
private static final String TAG = Migrations.class.getName();
|
private static final String TAG = Migrations.class.getName();
|
||||||
public static final boolean DEBUG = MainActivity.DEBUG;
|
public static final boolean DEBUG = MainActivity.DEBUG;
|
||||||
|
@ -179,5 +180,14 @@ public final class Migrations {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private Migrations() { }
|
public static final Migration MIGRATION_4_5 = new Migration(DB_VER_4, DB_VER_5) {
|
||||||
|
@Override
|
||||||
|
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||||
|
database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` "
|
||||||
|
+ "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private Migrations() {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import org.schabi.newpipe.database.feed.model.FeedEntity
|
||||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
||||||
import org.schabi.newpipe.database.stream.StreamWithState
|
import org.schabi.newpipe.database.stream.StreamWithState
|
||||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
||||||
|
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
@ -252,4 +253,21 @@ abstract class FeedDAO {
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
abstract fun getAllOutdatedForGroup(groupId: Long, outdatedThreshold: OffsetDateTime): Flowable<List<SubscriptionEntity>>
|
abstract fun getAllOutdatedForGroup(groupId: Long, outdatedThreshold: OffsetDateTime): Flowable<List<SubscriptionEntity>>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT s.* FROM subscriptions s
|
||||||
|
|
||||||
|
LEFT JOIN feed_last_updated lu
|
||||||
|
ON s.uid = lu.subscription_id
|
||||||
|
|
||||||
|
WHERE
|
||||||
|
(lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold)
|
||||||
|
AND s.notification_mode = :notificationMode
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
abstract fun getOutdatedWithNotificationMode(
|
||||||
|
outdatedThreshold: OffsetDateTime,
|
||||||
|
@NotificationMode notificationMode: Int
|
||||||
|
): Flowable<List<SubscriptionEntity>>
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ package org.schabi.newpipe.database.history.dao;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.room.Dao;
|
import androidx.room.Dao;
|
||||||
import androidx.room.Query;
|
import androidx.room.Query;
|
||||||
|
import androidx.room.RewriteQueriesToDropUnusedColumns;
|
||||||
|
|
||||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity;
|
import org.schabi.newpipe.database.history.model.StreamHistoryEntity;
|
||||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntry;
|
import org.schabi.newpipe.database.history.model.StreamHistoryEntry;
|
||||||
|
@ -67,6 +68,7 @@ public abstract class StreamHistoryDAO implements HistoryDAO<StreamHistoryEntity
|
||||||
@Query("DELETE FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
|
@Query("DELETE FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
|
||||||
public abstract int deleteStreamHistory(long streamId);
|
public abstract int deleteStreamHistory(long streamId);
|
||||||
|
|
||||||
|
@RewriteQueriesToDropUnusedColumns
|
||||||
@Query("SELECT * FROM " + STREAM_TABLE
|
@Query("SELECT * FROM " + STREAM_TABLE
|
||||||
|
|
||||||
// Select the latest entry and watch count for each stream id on history table
|
// Select the latest entry and watch count for each stream id on history table
|
||||||
|
|
|
@ -2,6 +2,7 @@ package org.schabi.newpipe.database.playlist.dao;
|
||||||
|
|
||||||
import androidx.room.Dao;
|
import androidx.room.Dao;
|
||||||
import androidx.room.Query;
|
import androidx.room.Query;
|
||||||
|
import androidx.room.RewriteQueriesToDropUnusedColumns;
|
||||||
import androidx.room.Transaction;
|
import androidx.room.Transaction;
|
||||||
|
|
||||||
import org.schabi.newpipe.database.BasicDAO;
|
import org.schabi.newpipe.database.BasicDAO;
|
||||||
|
@ -52,6 +53,7 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
|
||||||
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
|
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
|
||||||
Flowable<Integer> getMaximumIndexOf(long playlistId);
|
Flowable<Integer> getMaximumIndexOf(long playlistId);
|
||||||
|
|
||||||
|
@RewriteQueriesToDropUnusedColumns
|
||||||
@Transaction
|
@Transaction
|
||||||
@Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN "
|
@Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN "
|
||||||
// get ids of streams of the given playlist
|
// get ids of streams of the given playlist
|
||||||
|
|
|
@ -39,6 +39,9 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
|
||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
internal abstract fun silentInsertAllInternal(streams: List<StreamEntity>): List<Long>
|
internal abstract fun silentInsertAllInternal(streams: List<StreamEntity>): List<Long>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) != 0 FROM streams WHERE url = :url AND service_id = :serviceId")
|
||||||
|
internal abstract fun exists(serviceId: Int, url: String): Boolean
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration
|
SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
package org.schabi.newpipe.database.subscription;
|
||||||
|
|
||||||
|
import androidx.annotation.IntDef;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
|
||||||
|
@IntDef({NotificationMode.DISABLED, NotificationMode.ENABLED})
|
||||||
|
@Retention(RetentionPolicy.SOURCE)
|
||||||
|
public @interface NotificationMode {
|
||||||
|
|
||||||
|
int DISABLED = 0;
|
||||||
|
int ENABLED = 1;
|
||||||
|
//other values reserved for the future
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ import androidx.room.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
import io.reactivex.rxjava3.core.Flowable
|
import io.reactivex.rxjava3.core.Flowable
|
||||||
import io.reactivex.rxjava3.core.Maybe
|
import io.reactivex.rxjava3.core.Maybe
|
||||||
|
@ -31,6 +32,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
||||||
)
|
)
|
||||||
abstract fun getSubscriptionsFiltered(filter: String): Flowable<List<SubscriptionEntity>>
|
abstract fun getSubscriptionsFiltered(filter: String): Flowable<List<SubscriptionEntity>>
|
||||||
|
|
||||||
|
@RewriteQueriesToDropUnusedColumns
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM subscriptions s
|
SELECT * FROM subscriptions s
|
||||||
|
@ -47,6 +49,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
||||||
currentGroupId: Long
|
currentGroupId: Long
|
||||||
): Flowable<List<SubscriptionEntity>>
|
): Flowable<List<SubscriptionEntity>>
|
||||||
|
|
||||||
|
@RewriteQueriesToDropUnusedColumns
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT * FROM subscriptions s
|
SELECT * FROM subscriptions s
|
||||||
|
|
|
@ -26,6 +26,7 @@ public class SubscriptionEntity {
|
||||||
public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url";
|
public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url";
|
||||||
public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
|
public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
|
||||||
public static final String SUBSCRIPTION_DESCRIPTION = "description";
|
public static final String SUBSCRIPTION_DESCRIPTION = "description";
|
||||||
|
public static final String SUBSCRIPTION_NOTIFICATION_MODE = "notification_mode";
|
||||||
|
|
||||||
@PrimaryKey(autoGenerate = true)
|
@PrimaryKey(autoGenerate = true)
|
||||||
private long uid = 0;
|
private long uid = 0;
|
||||||
|
@ -48,6 +49,9 @@ public class SubscriptionEntity {
|
||||||
@ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
|
@ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
|
@ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE)
|
||||||
|
private int notificationMode;
|
||||||
|
|
||||||
@Ignore
|
@Ignore
|
||||||
public static SubscriptionEntity from(@NonNull final ChannelInfo info) {
|
public static SubscriptionEntity from(@NonNull final ChannelInfo info) {
|
||||||
final SubscriptionEntity result = new SubscriptionEntity();
|
final SubscriptionEntity result = new SubscriptionEntity();
|
||||||
|
@ -114,6 +118,15 @@ public class SubscriptionEntity {
|
||||||
this.description = description;
|
this.description = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NotificationMode
|
||||||
|
public int getNotificationMode() {
|
||||||
|
return notificationMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNotificationMode(@NotificationMode final int notificationMode) {
|
||||||
|
this.notificationMode = notificationMode;
|
||||||
|
}
|
||||||
|
|
||||||
@Ignore
|
@Ignore
|
||||||
public void setData(final String n, final String au, final String d, final Long sc) {
|
public void setData(final String n, final String au, final String d, final Long sc) {
|
||||||
this.setName(n);
|
this.setName(n);
|
||||||
|
|
|
@ -61,6 +61,7 @@ import org.schabi.newpipe.util.FilenameUtils;
|
||||||
import org.schabi.newpipe.util.ListHelper;
|
import org.schabi.newpipe.util.ListHelper;
|
||||||
import org.schabi.newpipe.util.PermissionHelper;
|
import org.schabi.newpipe.util.PermissionHelper;
|
||||||
import org.schabi.newpipe.util.SecondaryStreamHelper;
|
import org.schabi.newpipe.util.SecondaryStreamHelper;
|
||||||
|
import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
|
||||||
import org.schabi.newpipe.util.StreamItemAdapter;
|
import org.schabi.newpipe.util.StreamItemAdapter;
|
||||||
import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper;
|
import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper;
|
||||||
import org.schabi.newpipe.util.ThemeHelper;
|
import org.schabi.newpipe.util.ThemeHelper;
|
||||||
|
@ -154,7 +155,7 @@ public class DownloadDialog extends DialogFragment
|
||||||
public static DownloadDialog newInstance(final Context context, final StreamInfo info) {
|
public static DownloadDialog newInstance(final Context context, final StreamInfo info) {
|
||||||
final ArrayList<VideoStream> streamsList = new ArrayList<>(ListHelper
|
final ArrayList<VideoStream> streamsList = new ArrayList<>(ListHelper
|
||||||
.getSortedStreamVideosList(context, info.getVideoStreams(),
|
.getSortedStreamVideosList(context, info.getVideoStreams(),
|
||||||
info.getVideoOnlyStreams(), false));
|
info.getVideoOnlyStreams(), false, false));
|
||||||
final int selectedStreamIndex = ListHelper.getDefaultResolutionIndex(context, streamsList);
|
final int selectedStreamIndex = ListHelper.getDefaultResolutionIndex(context, streamsList);
|
||||||
|
|
||||||
final DownloadDialog instance = newInstance(info);
|
final DownloadDialog instance = newInstance(info);
|
||||||
|
@ -328,21 +329,15 @@ public class DownloadDialog extends DialogFragment
|
||||||
final int threads = prefs.getInt(getString(R.string.default_download_threads), 3);
|
final int threads = prefs.getInt(getString(R.string.default_download_threads), 3);
|
||||||
dialogBinding.threadsCount.setText(String.valueOf(threads));
|
dialogBinding.threadsCount.setText(String.valueOf(threads));
|
||||||
dialogBinding.threads.setProgress(threads - 1);
|
dialogBinding.threads.setProgress(threads - 1);
|
||||||
dialogBinding.threads.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
|
dialogBinding.threads.setOnSeekBarChangeListener(new SimpleOnSeekBarChangeListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onProgressChanged(final SeekBar seekbar, final int progress,
|
public void onProgressChanged(@NonNull final SeekBar seekbar, final int progress,
|
||||||
final boolean fromUser) {
|
final boolean fromUser) {
|
||||||
final int newProgress = progress + 1;
|
final int newProgress = progress + 1;
|
||||||
prefs.edit().putInt(getString(R.string.default_download_threads), newProgress)
|
prefs.edit().putInt(getString(R.string.default_download_threads), newProgress)
|
||||||
.apply();
|
.apply();
|
||||||
dialogBinding.threadsCount.setText(String.valueOf(newProgress));
|
dialogBinding.threadsCount.setText(String.valueOf(newProgress));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStartTrackingTouch(final SeekBar p1) { }
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStopTrackingTouch(final SeekBar p1) { }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
fetchStreamsSize();
|
fetchStreamsSize();
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package org.schabi.newpipe.error
|
package org.schabi.newpipe.error
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
@ -10,7 +9,7 @@ import android.os.Build
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
@ -114,13 +113,6 @@ class ErrorUtil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val notificationManager =
|
|
||||||
ContextCompat.getSystemService(context, NotificationManager::class.java)
|
|
||||||
if (notificationManager == null) {
|
|
||||||
// this should never happen, but just in case open error activity
|
|
||||||
openActivity(context, errorInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
var pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT
|
var pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
pendingIntentFlags = pendingIntentFlags or PendingIntent.FLAG_IMMUTABLE
|
pendingIntentFlags = pendingIntentFlags or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
@ -131,7 +123,13 @@ class ErrorUtil {
|
||||||
context,
|
context,
|
||||||
context.getString(R.string.error_report_channel_id)
|
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))
|
.setContentTitle(context.getString(R.string.error_report_notification_title))
|
||||||
.setContentText(context.getString(errorInfo.messageStringId))
|
.setContentText(context.getString(errorInfo.messageStringId))
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
|
@ -144,7 +142,8 @@ class ErrorUtil {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
notificationManager!!.notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build())
|
NotificationManagerCompat.from(context)
|
||||||
|
.notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build())
|
||||||
|
|
||||||
// since the notification is silent, also show a toast, otherwise the user is confused
|
// 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)
|
Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT)
|
||||||
|
|
|
@ -26,10 +26,11 @@ public enum UserAction {
|
||||||
DOWNLOAD_OPEN_DIALOG("download open dialog"),
|
DOWNLOAD_OPEN_DIALOG("download open dialog"),
|
||||||
DOWNLOAD_POSTPROCESSING("download post-processing"),
|
DOWNLOAD_POSTPROCESSING("download post-processing"),
|
||||||
DOWNLOAD_FAILED("download failed"),
|
DOWNLOAD_FAILED("download failed"),
|
||||||
|
NEW_STREAMS_NOTIFICATIONS("new streams notifications"),
|
||||||
PREFERENCES_MIGRATION("migration of preferences"),
|
PREFERENCES_MIGRATION("migration of preferences"),
|
||||||
SHARE_TO_NEWPIPE("share to newpipe"),
|
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;
|
private final String message;
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package org.schabi.newpipe.fragments;
|
package org.schabi.newpipe.fragments;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager;
|
import androidx.recyclerview.widget.StaggeredGridLayoutManager;
|
||||||
|
@ -10,7 +11,7 @@ import androidx.recyclerview.widget.StaggeredGridLayoutManager;
|
||||||
*/
|
*/
|
||||||
public abstract class OnScrollBelowItemsListener extends RecyclerView.OnScrollListener {
|
public abstract class OnScrollBelowItemsListener extends RecyclerView.OnScrollListener {
|
||||||
@Override
|
@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);
|
super.onScrolled(recyclerView, dx, dy);
|
||||||
if (dy > 0) {
|
if (dy > 0) {
|
||||||
int pastVisibleItems = 0;
|
int pastVisibleItems = 0;
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
package org.schabi.newpipe.fragments.detail;
|
package org.schabi.newpipe.fragments.detail;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||||
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
|
@ -46,6 +48,7 @@ class StackItem implements Serializable {
|
||||||
return playQueue;
|
return playQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return getServiceId() + ":" + getUrl() + " > " + getTitle();
|
return getServiceId() + ":" + getUrl() + " > " + getTitle();
|
||||||
|
|
|
@ -43,7 +43,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
||||||
import androidx.core.content.ContextCompat;
|
import androidx.core.content.ContextCompat;
|
||||||
import androidx.preference.PreferenceManager;
|
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.exoplayer2.PlaybackParameters;
|
||||||
import com.google.android.material.appbar.AppBarLayout;
|
import com.google.android.material.appbar.AppBarLayout;
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior;
|
import com.google.android.material.bottomsheet.BottomSheetBehavior;
|
||||||
|
@ -1639,6 +1639,7 @@ public final class VideoDetailFragment
|
||||||
activity,
|
activity,
|
||||||
info.getVideoStreams(),
|
info.getVideoStreams(),
|
||||||
info.getVideoOnlyStreams(),
|
info.getVideoOnlyStreams(),
|
||||||
|
false,
|
||||||
false);
|
false);
|
||||||
selectedVideoStreamIndex = ListHelper
|
selectedVideoStreamIndex = ListHelper
|
||||||
.getDefaultResolutionIndex(activity, sortedVideoStreams);
|
.getDefaultResolutionIndex(activity, sortedVideoStreams);
|
||||||
|
@ -1925,9 +1926,8 @@ public final class VideoDetailFragment
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onPlayerError(final ExoPlaybackException error) {
|
public void onPlayerError(final PlaybackException error, final boolean isCatchableException) {
|
||||||
if (error.type == ExoPlaybackException.TYPE_SOURCE
|
if (!isCatchableException) {
|
||||||
|| error.type == ExoPlaybackException.TYPE_UNEXPECTED) {
|
|
||||||
// Properly exit from fullscreen
|
// Properly exit from fullscreen
|
||||||
toggleFullscreenIfInFullscreenMode();
|
toggleFullscreenIfInFullscreenMode();
|
||||||
hideMainPlayerOnLoadingNewStream();
|
hideMainPlayerOnLoadingNewStream();
|
||||||
|
|
|
@ -15,6 +15,7 @@ import androidx.appcompat.app.AlertDialog;
|
||||||
|
|
||||||
import com.google.android.exoplayer2.C;
|
import com.google.android.exoplayer2.C;
|
||||||
import com.google.android.exoplayer2.ExoPlaybackException;
|
import com.google.android.exoplayer2.ExoPlaybackException;
|
||||||
|
import com.google.android.exoplayer2.PlaybackException;
|
||||||
|
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
|
import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
|
||||||
|
@ -28,6 +29,10 @@ import java.util.LinkedHashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.function.Supplier;
|
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}.
|
* Outsourced logic for crashing the player in the {@link VideoDetailFragment}.
|
||||||
*/
|
*/
|
||||||
|
@ -51,7 +56,8 @@ public final class VideoDetailPlayerCrasher {
|
||||||
exceptionTypes.put(
|
exceptionTypes.put(
|
||||||
"Source",
|
"Source",
|
||||||
() -> ExoPlaybackException.createForSource(
|
() -> ExoPlaybackException.createForSource(
|
||||||
new IOException(defaultMsg)
|
new IOException(defaultMsg),
|
||||||
|
ERROR_CODE_BEHIND_LIVE_WINDOW
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
exceptionTypes.put(
|
exceptionTypes.put(
|
||||||
|
@ -61,13 +67,16 @@ public final class VideoDetailPlayerCrasher {
|
||||||
"Dummy renderer",
|
"Dummy renderer",
|
||||||
0,
|
0,
|
||||||
null,
|
null,
|
||||||
C.FORMAT_HANDLED
|
C.FORMAT_HANDLED,
|
||||||
|
/*isRecoverable=*/false,
|
||||||
|
ERROR_CODE_DECODING_FAILED
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
exceptionTypes.put(
|
exceptionTypes.put(
|
||||||
"Unexpected",
|
"Unexpected",
|
||||||
() -> ExoPlaybackException.createForUnexpected(
|
() -> ExoPlaybackException.createForUnexpected(
|
||||||
new RuntimeException(defaultMsg)
|
new RuntimeException(defaultMsg),
|
||||||
|
ERROR_CODE_UNSPECIFIED
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
exceptionTypes.put(
|
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).
|
* 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 player
|
||||||
* @param exception
|
* @param exception
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package org.schabi.newpipe.fragments.list;
|
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.Context;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.content.res.Configuration;
|
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.comments.CommentsInfoItem;
|
||||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
|
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
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.BaseStateFragment;
|
||||||
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
|
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.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.NavigationHelper;
|
||||||
import org.schabi.newpipe.util.OnClickGesture;
|
import org.schabi.newpipe.util.OnClickGesture;
|
||||||
import org.schabi.newpipe.util.StateSaver;
|
import org.schabi.newpipe.util.StateSaver;
|
||||||
import org.schabi.newpipe.util.StreamDialogEntry;
|
|
||||||
import org.schabi.newpipe.views.SuperScrollLayoutManager;
|
import org.schabi.newpipe.views.SuperScrollLayoutManager;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Queue;
|
import java.util.Queue;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
|
||||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
|
||||||
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
|
|
||||||
|
|
||||||
public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||||
implements ListViewContract<I, N>, StateSaver.WriteRead,
|
implements ListViewContract<I, N>, StateSaver.WriteRead,
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
|
@ -268,11 +260,11 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void held(final StreamInfoItem selectedItem) {
|
public void held(final StreamInfoItem selectedItem) {
|
||||||
showStreamDialog(selectedItem);
|
showInfoItemDialog(selectedItem);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<ChannelInfoItem>() {
|
infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<>() {
|
||||||
@Override
|
@Override
|
||||||
public void selected(final ChannelInfoItem selectedItem) {
|
public void selected(final ChannelInfoItem selectedItem) {
|
||||||
try {
|
try {
|
||||||
|
@ -288,7 +280,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture<PlaylistInfoItem>() {
|
infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture<>() {
|
||||||
@Override
|
@Override
|
||||||
public void selected(final PlaylistInfoItem selectedItem) {
|
public void selected(final PlaylistInfoItem selectedItem) {
|
||||||
try {
|
try {
|
||||||
|
@ -350,7 +342,8 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||||
itemsList.clearOnScrollListeners();
|
itemsList.clearOnScrollListeners();
|
||||||
itemsList.addOnScrollListener(new DefaultItemListOnScrolledDownListener() {
|
itemsList.addOnScrollListener(new DefaultItemListOnScrolledDownListener() {
|
||||||
@Override
|
@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);
|
super.onScrolled(recyclerView, dx, dy);
|
||||||
|
|
||||||
if (dy != 0) {
|
if (dy != 0) {
|
||||||
|
@ -409,55 +402,12 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void showStreamDialog(final StreamInfoItem item) {
|
protected void showInfoItemDialog(final StreamInfoItem item) {
|
||||||
final Context context = getContext();
|
try {
|
||||||
final Activity activity = getActivity();
|
new InfoItemDialog.Builder(getActivity(), getContext(), this, item).create().show();
|
||||||
if (context == null || context.getResources() == null || activity == null) {
|
} catch (final IllegalArgumentException e) {
|
||||||
return;
|
InfoItemDialog.Builder.reportErrorDuringInitialization(e, item);
|
||||||
}
|
}
|
||||||
final List<StreamDialogEntry> entries = new ArrayList<>();
|
|
||||||
|
|
||||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
|
||||||
entries.add(StreamDialogEntry.enqueue);
|
|
||||||
|
|
||||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
|
||||||
entries.add(StreamDialogEntry.enqueue_next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.getStreamType() == StreamType.AUDIO_STREAM) {
|
|
||||||
entries.addAll(Arrays.asList(
|
|
||||||
StreamDialogEntry.start_here_on_background,
|
|
||||||
StreamDialogEntry.append_playlist,
|
|
||||||
StreamDialogEntry.share
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
entries.addAll(Arrays.asList(
|
|
||||||
StreamDialogEntry.start_here_on_background,
|
|
||||||
StreamDialogEntry.start_here_on_popup,
|
|
||||||
StreamDialogEntry.append_playlist,
|
|
||||||
StreamDialogEntry.share
|
|
||||||
));
|
|
||||||
}
|
|
||||||
entries.add(StreamDialogEntry.open_in_browser);
|
|
||||||
if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) {
|
|
||||||
entries.add(StreamDialogEntry.play_with_kodi);
|
|
||||||
}
|
|
||||||
|
|
||||||
// show "mark as watched" only when watch history is enabled
|
|
||||||
if (StreamDialogEntry.shouldAddMarkAsWatched(item.getStreamType(), context)) {
|
|
||||||
entries.add(
|
|
||||||
StreamDialogEntry.mark_as_watched
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!isNullOrEmpty(item.getUploaderUrl())) {
|
|
||||||
entries.add(StreamDialogEntry.show_channel_details);
|
|
||||||
}
|
|
||||||
|
|
||||||
StreamDialogEntry.setEnabledEntries(entries);
|
|
||||||
|
|
||||||
new InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context),
|
|
||||||
(dialog, which) -> StreamDialogEntry.clickOn(which, this, item)).show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
|
|
@ -9,6 +9,7 @@ import androidx.annotation.NonNull;
|
||||||
|
|
||||||
import org.schabi.newpipe.error.ErrorInfo;
|
import org.schabi.newpipe.error.ErrorInfo;
|
||||||
import org.schabi.newpipe.error.UserAction;
|
import org.schabi.newpipe.error.UserAction;
|
||||||
|
import org.schabi.newpipe.extractor.InfoItem;
|
||||||
import org.schabi.newpipe.extractor.ListExtractor;
|
import org.schabi.newpipe.extractor.ListExtractor;
|
||||||
import org.schabi.newpipe.extractor.ListInfo;
|
import org.schabi.newpipe.extractor.ListInfo;
|
||||||
import org.schabi.newpipe.extractor.Page;
|
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.disposables.Disposable;
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||||
|
|
||||||
public abstract class BaseListInfoFragment<I extends ListInfo>
|
public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInfo<I>>
|
||||||
extends BaseListFragment<I, ListExtractor.InfoItemsPage> {
|
extends BaseListFragment<L, ListExtractor.InfoItemsPage<I>> {
|
||||||
@State
|
@State
|
||||||
protected int serviceId = Constants.NO_SERVICE_ID;
|
protected int serviceId = Constants.NO_SERVICE_ID;
|
||||||
@State
|
@State
|
||||||
|
@ -37,7 +38,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||||
protected String url;
|
protected String url;
|
||||||
|
|
||||||
private final UserAction errorUserAction;
|
private final UserAction errorUserAction;
|
||||||
protected I currentInfo;
|
protected L currentInfo;
|
||||||
protected Page currentNextPage;
|
protected Page currentNextPage;
|
||||||
protected Disposable currentWorker;
|
protected Disposable currentWorker;
|
||||||
|
|
||||||
|
@ -97,7 +98,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public void readFrom(@NonNull final Queue<Object> savedObjects) throws Exception {
|
public void readFrom(@NonNull final Queue<Object> savedObjects) throws Exception {
|
||||||
super.readFrom(savedObjects);
|
super.readFrom(savedObjects);
|
||||||
currentInfo = (I) savedObjects.poll();
|
currentInfo = (L) savedObjects.poll();
|
||||||
currentNextPage = (Page) savedObjects.poll();
|
currentNextPage = (Page) savedObjects.poll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,7 +125,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||||
* @param forceLoad allow or disallow the result to come from the cache
|
* @param forceLoad allow or disallow the result to come from the cache
|
||||||
* @return Rx {@link Single} containing the {@link ListInfo}
|
* @return Rx {@link Single} containing the {@link ListInfo}
|
||||||
*/
|
*/
|
||||||
protected abstract Single<I> loadResult(boolean forceLoad);
|
protected abstract Single<L> loadResult(boolean forceLoad);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void startLoading(final boolean forceLoad) {
|
public void startLoading(final boolean forceLoad) {
|
||||||
|
@ -140,7 +141,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||||
currentWorker = loadResult(forceLoad)
|
currentWorker = loadResult(forceLoad)
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe((@NonNull I result) -> {
|
.subscribe((@NonNull L result) -> {
|
||||||
isLoading.set(false);
|
isLoading.set(false);
|
||||||
currentInfo = result;
|
currentInfo = result;
|
||||||
currentNextPage = result.getNextPage();
|
currentNextPage = result.getNextPage();
|
||||||
|
@ -157,7 +158,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||||
*
|
*
|
||||||
* @return Rx {@link Single} containing the {@link ListExtractor.InfoItemsPage}
|
* @return Rx {@link Single} containing the {@link ListExtractor.InfoItemsPage}
|
||||||
*/
|
*/
|
||||||
protected abstract Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic();
|
protected abstract Single<ListExtractor.InfoItemsPage<I>> loadMoreItemsLogic();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void loadMoreItems() {
|
protected void loadMoreItems() {
|
||||||
|
@ -194,7 +195,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void handleNextItems(final ListExtractor.InfoItemsPage result) {
|
public void handleNextItems(final ListExtractor.InfoItemsPage<I> result) {
|
||||||
super.handleNextItems(result);
|
super.handleNextItems(result);
|
||||||
|
|
||||||
currentNextPage = result.getNextPage();
|
currentNextPage = result.getNextPage();
|
||||||
|
@ -218,7 +219,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void handleResult(@NonNull final I result) {
|
public void handleResult(@NonNull final L result) {
|
||||||
super.handleResult(result);
|
super.handleResult(result);
|
||||||
|
|
||||||
name = result.getName();
|
name = result.getName();
|
||||||
|
|
|
@ -5,6 +5,7 @@ import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||||
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
|
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.graphics.Color;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
@ -22,9 +23,11 @@ import androidx.annotation.Nullable;
|
||||||
import androidx.appcompat.app.ActionBar;
|
import androidx.appcompat.app.ActionBar;
|
||||||
import androidx.core.content.ContextCompat;
|
import androidx.core.content.ContextCompat;
|
||||||
|
|
||||||
|
import com.google.android.material.snackbar.Snackbar;
|
||||||
import com.jakewharton.rxbinding4.view.RxView;
|
import com.jakewharton.rxbinding4.view.RxView;
|
||||||
|
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.database.subscription.NotificationMode;
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||||
import org.schabi.newpipe.databinding.ChannelHeaderBinding;
|
import org.schabi.newpipe.databinding.ChannelHeaderBinding;
|
||||||
import org.schabi.newpipe.databinding.FragmentChannelBinding;
|
import org.schabi.newpipe.databinding.FragmentChannelBinding;
|
||||||
|
@ -39,6 +42,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||||
import org.schabi.newpipe.ktx.AnimationType;
|
import org.schabi.newpipe.ktx.AnimationType;
|
||||||
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||||
|
import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
|
||||||
import org.schabi.newpipe.player.MainPlayer.PlayerType;
|
import org.schabi.newpipe.player.MainPlayer.PlayerType;
|
||||||
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
|
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||||
|
@ -64,7 +68,7 @@ import io.reactivex.rxjava3.functions.Consumer;
|
||||||
import io.reactivex.rxjava3.functions.Function;
|
import io.reactivex.rxjava3.functions.Function;
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||||
|
|
||||||
public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, ChannelInfo>
|
||||||
implements View.OnClickListener {
|
implements View.OnClickListener {
|
||||||
|
|
||||||
private static final int BUTTON_DEBOUNCE_INTERVAL = 100;
|
private static final int BUTTON_DEBOUNCE_INTERVAL = 100;
|
||||||
|
@ -84,6 +88,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||||
private PlaylistControlBinding playlistControlBinding;
|
private PlaylistControlBinding playlistControlBinding;
|
||||||
|
|
||||||
private MenuItem menuRssButton;
|
private MenuItem menuRssButton;
|
||||||
|
private MenuItem menuNotifyButton;
|
||||||
|
|
||||||
public static ChannelFragment getInstance(final int serviceId, final String url,
|
public static ChannelFragment getInstance(final int serviceId, final String url,
|
||||||
final String name) {
|
final String name) {
|
||||||
|
@ -179,6 +184,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||||
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
|
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||||
}
|
}
|
||||||
menuRssButton = menu.findItem(R.id.menu_item_rss);
|
menuRssButton = menu.findItem(R.id.menu_item_rss);
|
||||||
|
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -188,6 +194,11 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||||
case R.id.action_settings:
|
case R.id.action_settings:
|
||||||
NavigationHelper.openSettings(requireContext());
|
NavigationHelper.openSettings(requireContext());
|
||||||
break;
|
break;
|
||||||
|
case R.id.menu_item_notify:
|
||||||
|
final boolean value = !item.isChecked();
|
||||||
|
item.setEnabled(false);
|
||||||
|
setNotify(value);
|
||||||
|
break;
|
||||||
case R.id.menu_item_rss:
|
case R.id.menu_item_rss:
|
||||||
if (currentInfo != null) {
|
if (currentInfo != null) {
|
||||||
ShareUtils.openUrlInBrowser(
|
ShareUtils.openUrlInBrowser(
|
||||||
|
@ -232,15 +243,22 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||||
.subscribe(getSubscribeUpdateMonitor(info), onError));
|
.subscribe(getSubscribeUpdateMonitor(info), onError));
|
||||||
|
|
||||||
disposables.add(observable
|
disposables.add(observable
|
||||||
// Some updates are very rapid
|
.map(List::isEmpty)
|
||||||
// (for example when calling the updateSubscription(info))
|
.distinctUntilChanged()
|
||||||
// so only update the UI for the latest emission
|
|
||||||
// ("sync" the subscribe button's state)
|
|
||||||
.debounce(100, TimeUnit.MILLISECONDS)
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe((List<SubscriptionEntity> subscriptionEntities) ->
|
.subscribe(isEmpty -> updateSubscribeButton(!isEmpty), onError));
|
||||||
updateSubscribeButton(!subscriptionEntities.isEmpty()), onError));
|
|
||||||
|
|
||||||
|
disposables.add(observable
|
||||||
|
.map(List::isEmpty)
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.skip(1) // channel has just been opened
|
||||||
|
.filter(x -> NotificationHelper.areNewStreamsNotificationsEnabled(requireContext()))
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(isEmpty -> {
|
||||||
|
if (!isEmpty) {
|
||||||
|
showNotifySnackbar();
|
||||||
|
}
|
||||||
|
}, onError));
|
||||||
}
|
}
|
||||||
|
|
||||||
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription,
|
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription,
|
||||||
|
@ -320,6 +338,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||||
info.getAvatarUrl(),
|
info.getAvatarUrl(),
|
||||||
info.getDescription(),
|
info.getDescription(),
|
||||||
info.getSubscriberCount());
|
info.getSubscriberCount());
|
||||||
|
updateNotifyButton(null);
|
||||||
subscribeButtonMonitor = monitorSubscribeButton(
|
subscribeButtonMonitor = monitorSubscribeButton(
|
||||||
headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info));
|
headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info));
|
||||||
} else {
|
} else {
|
||||||
|
@ -327,6 +346,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||||
Log.d(TAG, "Found subscription to this channel!");
|
Log.d(TAG, "Found subscription to this channel!");
|
||||||
}
|
}
|
||||||
final SubscriptionEntity subscription = subscriptionEntities.get(0);
|
final SubscriptionEntity subscription = subscriptionEntities.get(0);
|
||||||
|
updateNotifyButton(subscription);
|
||||||
subscribeButtonMonitor = monitorSubscribeButton(
|
subscribeButtonMonitor = monitorSubscribeButton(
|
||||||
headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription));
|
headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription));
|
||||||
}
|
}
|
||||||
|
@ -369,12 +389,51 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||||
AnimationType.LIGHT_SCALE_AND_ALPHA);
|
AnimationType.LIGHT_SCALE_AND_ALPHA);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) {
|
||||||
|
if (menuNotifyButton == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (subscription != null) {
|
||||||
|
menuNotifyButton.setEnabled(
|
||||||
|
NotificationHelper.areNewStreamsNotificationsEnabled(requireContext())
|
||||||
|
);
|
||||||
|
menuNotifyButton.setChecked(
|
||||||
|
subscription.getNotificationMode() == NotificationMode.ENABLED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
menuNotifyButton.setVisible(subscription != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setNotify(final boolean isEnabled) {
|
||||||
|
disposables.add(
|
||||||
|
subscriptionManager
|
||||||
|
.updateNotificationMode(
|
||||||
|
currentInfo.getServiceId(),
|
||||||
|
currentInfo.getUrl(),
|
||||||
|
isEnabled ? NotificationMode.ENABLED : NotificationMode.DISABLED)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a snackbar with the option to enable notifications on new streams for this channel.
|
||||||
|
*/
|
||||||
|
private void showNotifySnackbar() {
|
||||||
|
Snackbar.make(itemsList, R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG)
|
||||||
|
.setAction(R.string.get_notified, v -> setNotify(true))
|
||||||
|
.setActionTextColor(Color.YELLOW)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
// Load and handle
|
// Load and handle
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
protected Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
|
||||||
return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPage);
|
return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,7 @@ import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.error.UserAction;
|
import org.schabi.newpipe.error.UserAction;
|
||||||
import org.schabi.newpipe.extractor.ListExtractor;
|
import org.schabi.newpipe.extractor.ListExtractor;
|
||||||
import org.schabi.newpipe.extractor.comments.CommentsInfo;
|
import org.schabi.newpipe.extractor.comments.CommentsInfo;
|
||||||
|
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||||
import org.schabi.newpipe.ktx.ViewUtils;
|
import org.schabi.newpipe.ktx.ViewUtils;
|
||||||
import org.schabi.newpipe.util.ExtractorHelper;
|
import org.schabi.newpipe.util.ExtractorHelper;
|
||||||
|
@ -22,7 +23,7 @@ import org.schabi.newpipe.util.ExtractorHelper;
|
||||||
import io.reactivex.rxjava3.core.Single;
|
import io.reactivex.rxjava3.core.Single;
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||||
|
|
||||||
public class CommentsFragment extends BaseListInfoFragment<CommentsInfo> {
|
public class CommentsFragment extends BaseListInfoFragment<CommentsInfoItem, CommentsInfo> {
|
||||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||||
|
|
||||||
private TextView emptyStateDesc;
|
private TextView emptyStateDesc;
|
||||||
|
@ -67,7 +68,7 @@ public class CommentsFragment extends BaseListInfoFragment<CommentsInfo> {
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
protected Single<ListExtractor.InfoItemsPage<CommentsInfoItem>> loadMoreItemsLogic() {
|
||||||
return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage);
|
return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,7 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||||
import org.schabi.newpipe.extractor.kiosk.KioskInfo;
|
import org.schabi.newpipe.extractor.kiosk.KioskInfo;
|
||||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory;
|
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory;
|
||||||
import org.schabi.newpipe.extractor.localization.ContentCountry;
|
import org.schabi.newpipe.extractor.localization.ContentCountry;
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||||
import org.schabi.newpipe.util.ExtractorHelper;
|
import org.schabi.newpipe.util.ExtractorHelper;
|
||||||
import org.schabi.newpipe.util.KioskTranslator;
|
import org.schabi.newpipe.util.KioskTranslator;
|
||||||
|
@ -53,7 +54,7 @@ import io.reactivex.rxjava3.core.Single;
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public class KioskFragment extends BaseListInfoFragment<KioskInfo> {
|
public class KioskFragment extends BaseListInfoFragment<StreamInfoItem, KioskInfo> {
|
||||||
@State
|
@State
|
||||||
String kioskId = "";
|
String kioskId = "";
|
||||||
String kioskTranslatedName;
|
String kioskTranslatedName;
|
||||||
|
@ -145,7 +146,7 @@ public class KioskFragment extends BaseListInfoFragment<KioskInfo> {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
public Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
|
||||||
return ExtractorHelper.getMoreKioskItems(serviceId, url, currentNextPage);
|
return ExtractorHelper.getMoreKioskItems(serviceId, url, currentNextPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
package org.schabi.newpipe.fragments.list.playlist;
|
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.animate;
|
||||||
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
|
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
|
||||||
|
|
||||||
import android.app.Activity;
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.content.res.ColorStateList;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
@ -19,6 +18,10 @@ import android.view.ViewGroup;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.appcompat.content.res.AppCompatResources;
|
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.Subscriber;
|
||||||
import org.reactivestreams.Subscription;
|
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.playlist.PlaylistInfo;
|
||||||
import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper;
|
import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
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.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.local.playlist.RemotePlaylistManager;
|
||||||
import org.schabi.newpipe.player.MainPlayer.PlayerType;
|
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.PlayQueue;
|
||||||
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
|
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
|
||||||
import org.schabi.newpipe.util.ExtractorHelper;
|
import org.schabi.newpipe.util.ExtractorHelper;
|
||||||
import org.schabi.newpipe.util.Localization;
|
import org.schabi.newpipe.util.Localization;
|
||||||
import org.schabi.newpipe.util.NavigationHelper;
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
import org.schabi.newpipe.util.PicassoHelper;
|
import org.schabi.newpipe.util.PicassoHelper;
|
||||||
import org.schabi.newpipe.util.StreamDialogEntry;
|
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
|
||||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
|
||||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.function.Supplier;
|
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.CompositeDisposable;
|
||||||
import io.reactivex.rxjava3.disposables.Disposable;
|
import io.reactivex.rxjava3.disposables.Disposable;
|
||||||
|
|
||||||
public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, PlaylistInfo> {
|
||||||
|
|
||||||
private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG";
|
private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG";
|
||||||
|
|
||||||
|
@ -140,60 +139,22 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void showStreamDialog(final StreamInfoItem item) {
|
protected void showInfoItemDialog(final StreamInfoItem item) {
|
||||||
final Context context = getContext();
|
final Context context = getContext();
|
||||||
final Activity activity = getActivity();
|
try {
|
||||||
if (context == null || context.getResources() == null || activity == null) {
|
final InfoItemDialog.Builder dialogBuilder =
|
||||||
return;
|
new InfoItemDialog.Builder(getActivity(), context, this, item);
|
||||||
|
|
||||||
|
dialogBuilder
|
||||||
|
.setAction(
|
||||||
|
StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND,
|
||||||
|
(f, infoItem) -> NavigationHelper.playOnBackgroundPlayer(
|
||||||
|
context, getPlayQueueStartingAt(infoItem), true))
|
||||||
|
.create()
|
||||||
|
.show();
|
||||||
|
} catch (final IllegalArgumentException e) {
|
||||||
|
InfoItemDialog.Builder.reportErrorDuringInitialization(e, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
final ArrayList<StreamDialogEntry> entries = new ArrayList<>();
|
|
||||||
|
|
||||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
|
||||||
entries.add(StreamDialogEntry.enqueue);
|
|
||||||
|
|
||||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
|
||||||
entries.add(StreamDialogEntry.enqueue_next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.getStreamType() == StreamType.AUDIO_STREAM) {
|
|
||||||
entries.addAll(Arrays.asList(
|
|
||||||
StreamDialogEntry.start_here_on_background,
|
|
||||||
StreamDialogEntry.append_playlist,
|
|
||||||
StreamDialogEntry.share
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
entries.addAll(Arrays.asList(
|
|
||||||
StreamDialogEntry.start_here_on_background,
|
|
||||||
StreamDialogEntry.start_here_on_popup,
|
|
||||||
StreamDialogEntry.append_playlist,
|
|
||||||
StreamDialogEntry.share
|
|
||||||
));
|
|
||||||
}
|
|
||||||
entries.add(StreamDialogEntry.open_in_browser);
|
|
||||||
if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) {
|
|
||||||
entries.add(StreamDialogEntry.play_with_kodi);
|
|
||||||
}
|
|
||||||
|
|
||||||
// show "mark as watched" only when watch history is enabled
|
|
||||||
if (StreamDialogEntry.shouldAddMarkAsWatched(item.getStreamType(), context)) {
|
|
||||||
entries.add(
|
|
||||||
StreamDialogEntry.mark_as_watched
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!isNullOrEmpty(item.getUploaderUrl())) {
|
|
||||||
entries.add(StreamDialogEntry.show_channel_details);
|
|
||||||
}
|
|
||||||
|
|
||||||
StreamDialogEntry.setEnabledEntries(entries);
|
|
||||||
|
|
||||||
StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItem) ->
|
|
||||||
NavigationHelper.playOnBackgroundPlayer(context,
|
|
||||||
getPlayQueueStartingAt(infoItem), true));
|
|
||||||
|
|
||||||
new InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context),
|
|
||||||
(dialog, which) -> StreamDialogEntry.clickOn(which, this, item)).show();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -249,7 +210,7 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
protected Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
|
||||||
return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPage);
|
return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -328,9 +289,14 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||||
&& (YoutubeParsingHelper.isYoutubeMixId(result.getId())
|
&& (YoutubeParsingHelper.isYoutubeMixId(result.getId())
|
||||||
|| YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) {
|
|| YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) {
|
||||||
// this is an auto-generated playlist (e.g. Youtube mix), so a radio is shown
|
// this is an auto-generated playlist (e.g. Youtube mix), so a radio is shown
|
||||||
headerBinding.uploaderAvatarView.setDisableCircularTransformation(true);
|
final ShapeAppearanceModel model = ShapeAppearanceModel.builder()
|
||||||
headerBinding.uploaderAvatarView.setBorderColor(
|
.setAllCorners(CornerFamily.ROUNDED, 0f)
|
||||||
getResources().getColor(R.color.transparent_background_color));
|
.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(
|
headerBinding.uploaderAvatarView.setImageDrawable(
|
||||||
AppCompatResources.getDrawable(requireContext(),
|
AppCompatResources.getDrawable(requireContext(),
|
||||||
R.drawable.ic_radio)
|
R.drawable.ic_radio)
|
||||||
|
|
|
@ -7,6 +7,7 @@ import android.view.ViewGroup;
|
||||||
import android.widget.ImageView;
|
import android.widget.ImageView;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
|
@ -34,8 +35,10 @@ public class SuggestionListAdapter
|
||||||
this.listener = listener;
|
this.listener = listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
@Override
|
@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)
|
return new SuggestionItemHolder(LayoutInflater.from(context)
|
||||||
.inflate(R.layout.item_search_suggestion, parent, false));
|
.inflate(R.layout.item_search_suggestion, parent, false));
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ import androidx.preference.PreferenceManager;
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.databinding.RelatedItemsHeaderBinding;
|
import org.schabi.newpipe.databinding.RelatedItemsHeaderBinding;
|
||||||
import org.schabi.newpipe.error.UserAction;
|
import org.schabi.newpipe.error.UserAction;
|
||||||
|
import org.schabi.newpipe.extractor.InfoItem;
|
||||||
import org.schabi.newpipe.extractor.ListExtractor;
|
import org.schabi.newpipe.extractor.ListExtractor;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||||
|
@ -26,7 +27,7 @@ import java.util.function.Supplier;
|
||||||
|
|
||||||
import io.reactivex.rxjava3.core.Single;
|
import io.reactivex.rxjava3.core.Single;
|
||||||
|
|
||||||
public class RelatedItemsFragment extends BaseListInfoFragment<RelatedItemInfo>
|
public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, RelatedItemInfo>
|
||||||
implements SharedPreferences.OnSharedPreferenceChangeListener {
|
implements SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
private static final String INFO_KEY = "related_info_key";
|
private static final String INFO_KEY = "related_info_key";
|
||||||
|
|
||||||
|
@ -86,7 +87,7 @@ public class RelatedItemsFragment extends BaseListInfoFragment<RelatedItemInfo>
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
protected Single<ListExtractor.InfoItemsPage<InfoItem>> loadMoreItemsLogic() {
|
||||||
return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage);
|
return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,54 +0,0 @@
|
||||||
package org.schabi.newpipe.info_list;
|
|
||||||
|
|
||||||
import android.app.Activity;
|
|
||||||
import android.content.DialogInterface;
|
|
||||||
import android.view.View;
|
|
||||||
import android.widget.TextView;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.appcompat.app.AlertDialog;
|
|
||||||
|
|
||||||
import org.schabi.newpipe.R;
|
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
|
||||||
|
|
||||||
public class InfoItemDialog {
|
|
||||||
private final AlertDialog dialog;
|
|
||||||
|
|
||||||
public InfoItemDialog(@NonNull final Activity activity,
|
|
||||||
@NonNull final StreamInfoItem info,
|
|
||||||
@NonNull final String[] commands,
|
|
||||||
@NonNull final DialogInterface.OnClickListener actions) {
|
|
||||||
this(activity, commands, actions, info.getName(), info.getUploaderName());
|
|
||||||
}
|
|
||||||
|
|
||||||
public InfoItemDialog(@NonNull final Activity activity,
|
|
||||||
@NonNull final String[] commands,
|
|
||||||
@NonNull final DialogInterface.OnClickListener actions,
|
|
||||||
@NonNull final String title,
|
|
||||||
@Nullable final String additionalDetail) {
|
|
||||||
|
|
||||||
final View bannerView = View.inflate(activity, R.layout.dialog_title, null);
|
|
||||||
bannerView.setSelected(true);
|
|
||||||
|
|
||||||
final TextView titleView = bannerView.findViewById(R.id.itemTitleView);
|
|
||||||
titleView.setText(title);
|
|
||||||
|
|
||||||
final TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails);
|
|
||||||
if (additionalDetail != null) {
|
|
||||||
detailsView.setText(additionalDetail);
|
|
||||||
detailsView.setVisibility(View.VISIBLE);
|
|
||||||
} else {
|
|
||||||
detailsView.setVisibility(View.GONE);
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog = new AlertDialog.Builder(activity)
|
|
||||||
.setCustomTitle(bannerView)
|
|
||||||
.setItems(commands, actions)
|
|
||||||
.create();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void show() {
|
|
||||||
dialog.show();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,356 @@
|
||||||
|
package org.schabi.newpipe.info_list.dialog;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.DialogInterface;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.appcompat.app.AlertDialog;
|
||||||
|
import androidx.fragment.app.Fragment;
|
||||||
|
import androidx.preference.PreferenceManager;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.App;
|
||||||
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.error.ErrorInfo;
|
||||||
|
import org.schabi.newpipe.error.ErrorUtil;
|
||||||
|
import org.schabi.newpipe.error.UserAction;
|
||||||
|
import org.schabi.newpipe.extractor.InfoItem;
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||||
|
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||||
|
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog for a {@link StreamInfoItem}.
|
||||||
|
* The dialog's content are actions that can be performed on the {@link StreamInfoItem}.
|
||||||
|
* This dialog is mostly used for longpress context menus.
|
||||||
|
*/
|
||||||
|
public final class InfoItemDialog {
|
||||||
|
private static final String TAG = Build.class.getSimpleName();
|
||||||
|
/**
|
||||||
|
* Ideally, {@link InfoItemDialog} would extend {@link AlertDialog}.
|
||||||
|
* However, extending {@link AlertDialog} requires many additional lines
|
||||||
|
* and brings more complexity to this class, especially the constructor.
|
||||||
|
* To circumvent this, an {@link AlertDialog.Builder} is used in the constructor.
|
||||||
|
* Its result is stored in this class variable to allow access via the {@link #show()} method.
|
||||||
|
*/
|
||||||
|
private final AlertDialog dialog;
|
||||||
|
|
||||||
|
private InfoItemDialog(@NonNull final Activity activity,
|
||||||
|
@NonNull final Fragment fragment,
|
||||||
|
@NonNull final StreamInfoItem info,
|
||||||
|
@NonNull final List<StreamDialogEntry> entries) {
|
||||||
|
|
||||||
|
// Create the dialog's title
|
||||||
|
final View bannerView = View.inflate(activity, R.layout.dialog_title, null);
|
||||||
|
bannerView.setSelected(true);
|
||||||
|
|
||||||
|
final TextView titleView = bannerView.findViewById(R.id.itemTitleView);
|
||||||
|
titleView.setText(info.getName());
|
||||||
|
|
||||||
|
final TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails);
|
||||||
|
if (info.getUploaderName() != null) {
|
||||||
|
detailsView.setText(info.getUploaderName());
|
||||||
|
detailsView.setVisibility(View.VISIBLE);
|
||||||
|
} else {
|
||||||
|
detailsView.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the entry's descriptions which are displayed in the dialog
|
||||||
|
final String[] items = entries.stream()
|
||||||
|
.map(entry -> entry.getString(activity)).toArray(String[]::new);
|
||||||
|
|
||||||
|
// Call an entry's action / onClick method when the entry is selected.
|
||||||
|
final DialogInterface.OnClickListener action = (d, index) ->
|
||||||
|
entries.get(index).action.onClick(fragment, info);
|
||||||
|
|
||||||
|
dialog = new AlertDialog.Builder(activity)
|
||||||
|
.setCustomTitle(bannerView)
|
||||||
|
.setItems(items, action)
|
||||||
|
.create();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public void show() {
|
||||||
|
dialog.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>Builder to generate a {@link InfoItemDialog} for a {@link StreamInfoItem}.</p>
|
||||||
|
* Use {@link #addEntry(StreamDialogDefaultEntry)}
|
||||||
|
* and {@link #addAllEntries(StreamDialogDefaultEntry...)} to add options to the dialog.
|
||||||
|
* <br>
|
||||||
|
* Custom actions for entries can be set using
|
||||||
|
* {@link #setAction(StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)}.
|
||||||
|
*/
|
||||||
|
public static class Builder {
|
||||||
|
@NonNull private final Activity activity;
|
||||||
|
@NonNull private final Context context;
|
||||||
|
@NonNull private final StreamInfoItem infoItem;
|
||||||
|
@NonNull private final Fragment fragment;
|
||||||
|
@NonNull private final List<StreamDialogEntry> entries = new ArrayList<>();
|
||||||
|
private final boolean addDefaultEntriesAutomatically;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>Create a {@link Builder builder} instance for a {@link StreamInfoItem}
|
||||||
|
* that automatically adds the some default entries
|
||||||
|
* at the top and bottom of the dialog.</p>
|
||||||
|
* The dialog has the following structure:
|
||||||
|
* <pre>
|
||||||
|
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||||
|
* | ENQUEUE |
|
||||||
|
* | ENQUEUE_NEXT |
|
||||||
|
* | START_ON_BACKGROUND |
|
||||||
|
* | START_ON_POPUP |
|
||||||
|
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||||
|
* | entries added manually with |
|
||||||
|
* | addEntry() and addAllEntries() |
|
||||||
|
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||||
|
* | APPEND_PLAYLIST |
|
||||||
|
* | SHARE |
|
||||||
|
* | OPEN_IN_BROWSER |
|
||||||
|
* | PLAY_WITH_KODI |
|
||||||
|
* | MARK_AS_WATCHED |
|
||||||
|
* | SHOW_CHANNEL_DETAILS |
|
||||||
|
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||||
|
* </pre>
|
||||||
|
* Please note that some entries are not added depending on the user's preferences,
|
||||||
|
* the item's {@link StreamType} and the current player state.
|
||||||
|
*
|
||||||
|
* @param activity
|
||||||
|
* @param context
|
||||||
|
* @param fragment
|
||||||
|
* @param infoItem the item for this dialog; all entries and their actions work with
|
||||||
|
* this {@link StreamInfoItem}
|
||||||
|
* @throws IllegalArgumentException if <code>activity, context</code>
|
||||||
|
* or resources is <code>null</code>
|
||||||
|
*/
|
||||||
|
public Builder(final Activity activity,
|
||||||
|
final Context context,
|
||||||
|
@NonNull final Fragment fragment,
|
||||||
|
@NonNull final StreamInfoItem infoItem) {
|
||||||
|
this(activity, context, fragment, infoItem, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>Create an instance of this {@link Builder} for a {@link StreamInfoItem}.</p>
|
||||||
|
* <p>If {@code addDefaultEntriesAutomatically} is set to {@code true},
|
||||||
|
* some default entries are added to the top and bottom of the dialog.</p>
|
||||||
|
* The dialog has the following structure:
|
||||||
|
* <pre>
|
||||||
|
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||||
|
* | ENQUEUE |
|
||||||
|
* | ENQUEUE_NEXT |
|
||||||
|
* | START_ON_BACKGROUND |
|
||||||
|
* | START_ON_POPUP |
|
||||||
|
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||||
|
* | entries added manually with |
|
||||||
|
* | addEntry() and addAllEntries() |
|
||||||
|
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||||
|
* | APPEND_PLAYLIST |
|
||||||
|
* | SHARE |
|
||||||
|
* | OPEN_IN_BROWSER |
|
||||||
|
* | PLAY_WITH_KODI |
|
||||||
|
* | MARK_AS_WATCHED |
|
||||||
|
* | SHOW_CHANNEL_DETAILS |
|
||||||
|
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||||
|
* </pre>
|
||||||
|
* Please note that some entries are not added depending on the user's preferences,
|
||||||
|
* the item's {@link StreamType} and the current player state.
|
||||||
|
*
|
||||||
|
* @param activity
|
||||||
|
* @param context
|
||||||
|
* @param fragment
|
||||||
|
* @param infoItem
|
||||||
|
* @param addDefaultEntriesAutomatically
|
||||||
|
* whether default entries added with {@link #addDefaultBeginningEntries()}
|
||||||
|
* and {@link #addDefaultEndEntries()} are added automatically when generating
|
||||||
|
* the {@link InfoItemDialog}.
|
||||||
|
* <br/>
|
||||||
|
* Entries added with {@link #addEntry(StreamDialogDefaultEntry)} and
|
||||||
|
* {@link #addAllEntries(StreamDialogDefaultEntry...)} are added in between.
|
||||||
|
* @throws IllegalArgumentException if <code>activity, context</code>
|
||||||
|
* or resources is <code>null</code>
|
||||||
|
*/
|
||||||
|
public Builder(final Activity activity,
|
||||||
|
final Context context,
|
||||||
|
@NonNull final Fragment fragment,
|
||||||
|
@NonNull final StreamInfoItem infoItem,
|
||||||
|
final boolean addDefaultEntriesAutomatically) {
|
||||||
|
if (activity == null || context == null || context.getResources() == null) {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "activity, context or resources is null: activity = "
|
||||||
|
+ activity + ", context = " + context);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("activity, context or resources is null");
|
||||||
|
}
|
||||||
|
this.activity = activity;
|
||||||
|
this.context = context;
|
||||||
|
this.fragment = fragment;
|
||||||
|
this.infoItem = infoItem;
|
||||||
|
this.addDefaultEntriesAutomatically = addDefaultEntriesAutomatically;
|
||||||
|
if (addDefaultEntriesAutomatically) {
|
||||||
|
addDefaultBeginningEntries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a new entry and appends it to the current entry list.
|
||||||
|
* @param entry the entry to add
|
||||||
|
* @return the current {@link Builder} instance
|
||||||
|
*/
|
||||||
|
public Builder addEntry(@NonNull final StreamDialogDefaultEntry entry) {
|
||||||
|
entries.add(entry.toStreamDialogEntry());
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds new entries. These are appended to the current entry list.
|
||||||
|
* @param newEntries the entries to add
|
||||||
|
* @return the current {@link Builder} instance
|
||||||
|
*/
|
||||||
|
public Builder addAllEntries(@NonNull final StreamDialogDefaultEntry... newEntries) {
|
||||||
|
Stream.of(newEntries).forEach(this::addEntry);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>Change an entries' action that is called when the entry is selected.</p>
|
||||||
|
* <p><strong>Warning:</strong> Only use this method when the entry has been already added.
|
||||||
|
* Changing the action of an entry which has not been added to the Builder yet
|
||||||
|
* does not have an effect.</p>
|
||||||
|
* @param entry the entry to change
|
||||||
|
* @param action the action to perform when the entry is selected
|
||||||
|
* @return the current {@link Builder} instance
|
||||||
|
*/
|
||||||
|
public Builder setAction(@NonNull final StreamDialogDefaultEntry entry,
|
||||||
|
@NonNull final StreamDialogEntry.StreamDialogEntryAction action) {
|
||||||
|
for (int i = 0; i < entries.size(); i++) {
|
||||||
|
if (entries.get(i).resource == entry.resource) {
|
||||||
|
entries.set(i, new StreamDialogEntry(entry.resource, action));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds {@link StreamDialogDefaultEntry#ENQUEUE} if the player is open and
|
||||||
|
* {@link StreamDialogDefaultEntry#ENQUEUE_NEXT} if there are multiple streams
|
||||||
|
* in the play queue.
|
||||||
|
* @return the current {@link Builder} instance
|
||||||
|
*/
|
||||||
|
public Builder addEnqueueEntriesIfNeeded() {
|
||||||
|
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
||||||
|
addEntry(StreamDialogDefaultEntry.ENQUEUE);
|
||||||
|
|
||||||
|
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
||||||
|
addEntry(StreamDialogDefaultEntry.ENQUEUE_NEXT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the {@link StreamDialogDefaultEntry#START_HERE_ON_BACKGROUND}.
|
||||||
|
* If the {@link #infoItem} is not a pure audio (live) stream,
|
||||||
|
* {@link StreamDialogDefaultEntry#START_HERE_ON_POPUP} is added, too.
|
||||||
|
* @return the current {@link Builder} instance
|
||||||
|
*/
|
||||||
|
public Builder addStartHereEntries() {
|
||||||
|
addEntry(StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND);
|
||||||
|
if (infoItem.getStreamType() != StreamType.AUDIO_STREAM
|
||||||
|
&& infoItem.getStreamType() != StreamType.AUDIO_LIVE_STREAM) {
|
||||||
|
addEntry(StreamDialogDefaultEntry.START_HERE_ON_POPUP);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds {@link StreamDialogDefaultEntry#MARK_AS_WATCHED} if the watch history is enabled
|
||||||
|
* and the stream is not a livestream.
|
||||||
|
* @return the current {@link Builder} instance
|
||||||
|
*/
|
||||||
|
public Builder addMarkAsWatchedEntryIfNeeded() {
|
||||||
|
final boolean isWatchHistoryEnabled = PreferenceManager
|
||||||
|
.getDefaultSharedPreferences(context)
|
||||||
|
.getBoolean(context.getString(R.string.enable_watch_history_key), false);
|
||||||
|
if (isWatchHistoryEnabled
|
||||||
|
&& infoItem.getStreamType() != StreamType.LIVE_STREAM
|
||||||
|
&& infoItem.getStreamType() != StreamType.AUDIO_LIVE_STREAM) {
|
||||||
|
addEntry(StreamDialogDefaultEntry.MARK_AS_WATCHED);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the {@link StreamDialogDefaultEntry#PLAY_WITH_KODI} entry if it is needed.
|
||||||
|
* @return the current {@link Builder} instance
|
||||||
|
*/
|
||||||
|
public Builder addPlayWithKodiEntryIfNeeded() {
|
||||||
|
if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
|
||||||
|
addEntry(StreamDialogDefaultEntry.PLAY_WITH_KODI);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the entries which are usually at the top of the action list.
|
||||||
|
* <br/>
|
||||||
|
* This method adds the "enqueue" (see {@link #addEnqueueEntriesIfNeeded()})
|
||||||
|
* and "start here" (see {@link #addStartHereEntries()} entries.
|
||||||
|
* @return the current {@link Builder} instance
|
||||||
|
*/
|
||||||
|
public Builder addDefaultBeginningEntries() {
|
||||||
|
addEnqueueEntriesIfNeeded();
|
||||||
|
addStartHereEntries();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the entries which are usually at the bottom of the action list.
|
||||||
|
* @return the current {@link Builder} instance
|
||||||
|
*/
|
||||||
|
public Builder addDefaultEndEntries() {
|
||||||
|
addAllEntries(
|
||||||
|
StreamDialogDefaultEntry.APPEND_PLAYLIST,
|
||||||
|
StreamDialogDefaultEntry.SHARE,
|
||||||
|
StreamDialogDefaultEntry.OPEN_IN_BROWSER
|
||||||
|
);
|
||||||
|
addPlayWithKodiEntryIfNeeded();
|
||||||
|
addMarkAsWatchedEntryIfNeeded();
|
||||||
|
addEntry(StreamDialogDefaultEntry.SHOW_CHANNEL_DETAILS);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the {@link InfoItemDialog}.
|
||||||
|
* @return a new instance of {@link InfoItemDialog}
|
||||||
|
*/
|
||||||
|
public InfoItemDialog create() {
|
||||||
|
if (addDefaultEntriesAutomatically) {
|
||||||
|
addDefaultEndEntries();
|
||||||
|
}
|
||||||
|
return new InfoItemDialog(this.activity, this.fragment, this.infoItem, this.entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void reportErrorDuringInitialization(final Throwable throwable,
|
||||||
|
final InfoItem item) {
|
||||||
|
ErrorUtil.showSnackbar(App.getApp().getBaseContext(), new ErrorInfo(
|
||||||
|
throwable,
|
||||||
|
UserAction.OPEN_INFO_ITEM_DIALOG,
|
||||||
|
"none",
|
||||||
|
item.getServiceId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,142 @@
|
||||||
|
package org.schabi.newpipe.info_list.dialog;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.util.NavigationHelper.openChannelFragment;
|
||||||
|
import static org.schabi.newpipe.util.SparseItemUtil.fetchItemInfoIfSparse;
|
||||||
|
import static org.schabi.newpipe.util.SparseItemUtil.fetchUploaderUrlIfSparse;
|
||||||
|
|
||||||
|
import android.net.Uri;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.StringRes;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||||
|
import org.schabi.newpipe.local.dialog.PlaylistAppendDialog;
|
||||||
|
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||||
|
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||||
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
|
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||||
|
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>
|
||||||
|
* This enum provides entries that are accepted
|
||||||
|
* by the {@link InfoItemDialog.Builder}.
|
||||||
|
* </p>
|
||||||
|
* <p>
|
||||||
|
* These entries contain a String {@link #resource} which is displayed in the dialog and
|
||||||
|
* a default {@link #action} that is executed
|
||||||
|
* when the entry is selected (via <code>onClick()</code>).
|
||||||
|
* <br/>
|
||||||
|
* They action can be overridden by using the Builder's
|
||||||
|
* {@link InfoItemDialog.Builder#setAction(
|
||||||
|
* StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)}
|
||||||
|
* method.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public enum StreamDialogDefaultEntry {
|
||||||
|
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) ->
|
||||||
|
fetchUploaderUrlIfSparse(fragment.requireContext(), item.getServiceId(), item.getUrl(),
|
||||||
|
item.getUploaderUrl(), url -> openChannelFragment(fragment, item, url))
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueues the stream automatically to the current PlayerType.
|
||||||
|
*/
|
||||||
|
ENQUEUE(R.string.enqueue_stream, (fragment, item) ->
|
||||||
|
fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue ->
|
||||||
|
NavigationHelper.enqueueOnPlayer(fragment.getContext(), singlePlayQueue))
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueues the stream automatically to the current PlayerType
|
||||||
|
* after the currently playing stream.
|
||||||
|
*/
|
||||||
|
ENQUEUE_NEXT(R.string.enqueue_next_stream, (fragment, item) ->
|
||||||
|
fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue ->
|
||||||
|
NavigationHelper.enqueueNextOnPlayer(fragment.getContext(), singlePlayQueue))
|
||||||
|
),
|
||||||
|
|
||||||
|
START_HERE_ON_BACKGROUND(R.string.start_here_on_background, (fragment, item) ->
|
||||||
|
fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue ->
|
||||||
|
NavigationHelper.playOnBackgroundPlayer(
|
||||||
|
fragment.getContext(), singlePlayQueue, true))),
|
||||||
|
|
||||||
|
START_HERE_ON_POPUP(R.string.start_here_on_popup, (fragment, item) ->
|
||||||
|
fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue ->
|
||||||
|
NavigationHelper.playOnPopupPlayer(fragment.getContext(), singlePlayQueue, true))),
|
||||||
|
|
||||||
|
SET_AS_PLAYLIST_THUMBNAIL(R.string.set_as_playlist_thumbnail, (fragment, item) -> {
|
||||||
|
throw new UnsupportedOperationException("This needs to be implemented manually "
|
||||||
|
+ "by using InfoItemDialog.Builder.setAction()");
|
||||||
|
}),
|
||||||
|
|
||||||
|
DELETE(R.string.delete, (fragment, item) -> {
|
||||||
|
throw new UnsupportedOperationException("This needs to be implemented manually "
|
||||||
|
+ "by using InfoItemDialog.Builder.setAction()");
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a {@link PlaylistDialog} to either append the stream to a playlist
|
||||||
|
* or create a new playlist if there are no local playlists.
|
||||||
|
*/
|
||||||
|
APPEND_PLAYLIST(R.string.add_to_playlist, (fragment, item) ->
|
||||||
|
PlaylistDialog.createCorrespondingDialog(
|
||||||
|
fragment.getContext(),
|
||||||
|
Collections.singletonList(new StreamEntity(item)),
|
||||||
|
dialog -> dialog.show(
|
||||||
|
fragment.getParentFragmentManager(),
|
||||||
|
"StreamDialogEntry@"
|
||||||
|
+ (dialog instanceof PlaylistAppendDialog ? "append" : "create")
|
||||||
|
+ "_playlist"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
PLAY_WITH_KODI(R.string.play_with_kodi_title, (fragment, item) -> {
|
||||||
|
final Uri videoUrl = Uri.parse(item.getUrl());
|
||||||
|
try {
|
||||||
|
NavigationHelper.playWithKore(fragment.requireContext(), videoUrl);
|
||||||
|
} catch (final Exception e) {
|
||||||
|
KoreUtils.showInstallKoreDialog(fragment.requireActivity());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
SHARE(R.string.share, (fragment, item) ->
|
||||||
|
ShareUtils.shareText(fragment.requireContext(), item.getName(), item.getUrl(),
|
||||||
|
item.getThumbnailUrl())),
|
||||||
|
|
||||||
|
OPEN_IN_BROWSER(R.string.open_in_browser, (fragment, item) ->
|
||||||
|
ShareUtils.openUrlInBrowser(fragment.requireContext(), item.getUrl())),
|
||||||
|
|
||||||
|
|
||||||
|
MARK_AS_WATCHED(R.string.mark_as_watched, (fragment, item) ->
|
||||||
|
new HistoryRecordManager(fragment.getContext())
|
||||||
|
.markAsWatched(item)
|
||||||
|
.onErrorComplete()
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
public final int resource;
|
||||||
|
@NonNull
|
||||||
|
public final StreamDialogEntry.StreamDialogEntryAction action;
|
||||||
|
|
||||||
|
StreamDialogDefaultEntry(@StringRes final int resource,
|
||||||
|
@NonNull final StreamDialogEntry.StreamDialogEntryAction action) {
|
||||||
|
this.resource = resource;
|
||||||
|
this.action = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public StreamDialogEntry toStreamDialogEntry() {
|
||||||
|
return new StreamDialogEntry(resource, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
package org.schabi.newpipe.info_list.dialog;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.StringRes;
|
||||||
|
import androidx.fragment.app.Fragment;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||||
|
|
||||||
|
public class StreamDialogEntry {
|
||||||
|
|
||||||
|
@StringRes
|
||||||
|
public final int resource;
|
||||||
|
@NonNull
|
||||||
|
public final StreamDialogEntryAction action;
|
||||||
|
|
||||||
|
public StreamDialogEntry(@StringRes final int resource,
|
||||||
|
@NonNull final StreamDialogEntryAction action) {
|
||||||
|
this.resource = resource;
|
||||||
|
this.action = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getString(@NonNull final Context context) {
|
||||||
|
return context.getString(resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface StreamDialogEntryAction {
|
||||||
|
void onClick(Fragment fragment, StreamInfoItem infoItem);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
package org.schabi.newpipe.info_list.holder;
|
package org.schabi.newpipe.info_list.holder;
|
||||||
|
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.ImageView;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
import org.schabi.newpipe.R;
|
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.PicassoHelper;
|
||||||
import org.schabi.newpipe.util.Localization;
|
import org.schabi.newpipe.util.Localization;
|
||||||
|
|
||||||
import de.hdodenhof.circleimageview.CircleImageView;
|
|
||||||
|
|
||||||
public class ChannelMiniInfoItemHolder extends InfoItemHolder {
|
public class ChannelMiniInfoItemHolder extends InfoItemHolder {
|
||||||
public final CircleImageView itemThumbnailView;
|
public final ImageView itemThumbnailView;
|
||||||
public final TextView itemTitleView;
|
public final TextView itemTitleView;
|
||||||
private final TextView itemAdditionalDetailView;
|
private final TextView itemAdditionalDetailView;
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ import android.text.util.Linkify;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.ImageView;
|
||||||
import android.widget.RelativeLayout;
|
import android.widget.RelativeLayout;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
@ -28,8 +29,6 @@ import org.schabi.newpipe.util.PicassoHelper;
|
||||||
|
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
|
|
||||||
import de.hdodenhof.circleimageview.CircleImageView;
|
|
||||||
|
|
||||||
public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
||||||
private static final String TAG = "CommentsMiniIIHolder";
|
private static final String TAG = "CommentsMiniIIHolder";
|
||||||
|
|
||||||
|
@ -40,7 +39,7 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
||||||
private final int commentVerticalPadding;
|
private final int commentVerticalPadding;
|
||||||
|
|
||||||
private final RelativeLayout itemRoot;
|
private final RelativeLayout itemRoot;
|
||||||
public final CircleImageView itemThumbnailView;
|
public final ImageView itemThumbnailView;
|
||||||
private final TextView itemContentView;
|
private final TextView itemContentView;
|
||||||
private final TextView itemLikesCountView;
|
private final TextView itemLikesCountView;
|
||||||
private final TextView itemPublishedTime;
|
private final TextView itemPublishedTime;
|
||||||
|
|
|
@ -228,6 +228,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("FinalParameters")
|
||||||
@Override
|
@Override
|
||||||
public int getItemViewType(int position) {
|
public int getItemViewType(int position) {
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
|
@ -300,6 +301,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("FinalParameters")
|
||||||
@Override
|
@Override
|
||||||
public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int position) {
|
public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int position) {
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
|
|
|
@ -33,8 +33,16 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
|
||||||
|
|
||||||
private final CompositeDisposable playlistDisposables = new CompositeDisposable();
|
private final CompositeDisposable playlistDisposables = new CompositeDisposable();
|
||||||
|
|
||||||
public PlaylistAppendDialog(final List<StreamEntity> streamEntities) {
|
/**
|
||||||
super(streamEntities);
|
* Create a new instance of {@link PlaylistAppendDialog}.
|
||||||
|
*
|
||||||
|
* @param streamEntities a list of {@link StreamEntity} to be added to playlists
|
||||||
|
* @return a new instance of {@link PlaylistAppendDialog}
|
||||||
|
*/
|
||||||
|
public static PlaylistAppendDialog newInstance(final List<StreamEntity> streamEntities) {
|
||||||
|
final PlaylistAppendDialog dialog = new PlaylistAppendDialog();
|
||||||
|
dialog.setStreamEntities(streamEntities);
|
||||||
|
return dialog;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
@ -103,13 +111,14 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
|
||||||
// Helper
|
// Helper
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
/** Display create playlist dialog. */
|
||||||
public void openCreatePlaylistDialog() {
|
public void openCreatePlaylistDialog() {
|
||||||
if (getStreamEntities() == null || !isAdded()) {
|
if (getStreamEntities() == null || !isAdded()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final PlaylistCreationDialog playlistCreationDialog =
|
final PlaylistCreationDialog playlistCreationDialog =
|
||||||
new PlaylistCreationDialog(getStreamEntities());
|
PlaylistCreationDialog.newInstance(getStreamEntities());
|
||||||
// Move the dismissListener to the new dialog.
|
// Move the dismissListener to the new dialog.
|
||||||
playlistCreationDialog.setOnDismissListener(this.getOnDismissListener());
|
playlistCreationDialog.setOnDismissListener(this.getOnDismissListener());
|
||||||
this.setOnDismissListener(null);
|
this.setOnDismissListener(null);
|
||||||
|
|
|
@ -21,8 +21,17 @@ import java.util.List;
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||||
|
|
||||||
public final class PlaylistCreationDialog extends PlaylistDialog {
|
public final class PlaylistCreationDialog extends PlaylistDialog {
|
||||||
public PlaylistCreationDialog(final List<StreamEntity> streamEntities) {
|
|
||||||
super(streamEntities);
|
/**
|
||||||
|
* Create a new instance of {@link PlaylistCreationDialog}.
|
||||||
|
*
|
||||||
|
* @param streamEntities a list of {@link StreamEntity} to be added to playlists
|
||||||
|
* @return a new instance of {@link PlaylistCreationDialog}
|
||||||
|
*/
|
||||||
|
public static PlaylistCreationDialog newInstance(final List<StreamEntity> streamEntities) {
|
||||||
|
final PlaylistCreationDialog dialog = new PlaylistCreationDialog();
|
||||||
|
dialog.setStreamEntities(streamEntities);
|
||||||
|
return dialog;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
|
|
@ -31,10 +31,6 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
|
||||||
|
|
||||||
private org.schabi.newpipe.util.SavedState savedState;
|
private org.schabi.newpipe.util.SavedState savedState;
|
||||||
|
|
||||||
public PlaylistDialog(final List<StreamEntity> streamEntities) {
|
|
||||||
this.streamEntities = streamEntities;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
// LifeCycle
|
// LifeCycle
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
@ -97,7 +93,7 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onSaveInstanceState(final Bundle outState) {
|
public void onSaveInstanceState(@NonNull final Bundle outState) {
|
||||||
super.onSaveInstanceState(outState);
|
super.onSaveInstanceState(outState);
|
||||||
if (getActivity() != null) {
|
if (getActivity() != null) {
|
||||||
savedState = StateSaver.tryToSave(getActivity().isChangingConfigurations(),
|
savedState = StateSaver.tryToSave(getActivity().isChangingConfigurations(),
|
||||||
|
@ -120,6 +116,10 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
|
||||||
this.onDismissListener = onDismissListener;
|
this.onDismissListener = onDismissListener;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected void setStreamEntities(final List<StreamEntity> streamEntities) {
|
||||||
|
this.streamEntities = streamEntities;
|
||||||
|
}
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
// Dialog creation
|
// Dialog creation
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
@ -143,8 +143,8 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(hasPlaylists ->
|
.subscribe(hasPlaylists ->
|
||||||
onExec.accept(hasPlaylists
|
onExec.accept(hasPlaylists
|
||||||
? new PlaylistAppendDialog(streamEntities)
|
? PlaylistAppendDialog.newInstance(streamEntities)
|
||||||
: new PlaylistCreationDialog(streamEntities))
|
: PlaylistCreationDialog.newInstance(streamEntities))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
||||||
import org.schabi.newpipe.database.stream.StreamWithState
|
import org.schabi.newpipe.database.stream.StreamWithState
|
||||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
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.StreamInfoItem
|
||||||
import org.schabi.newpipe.extractor.stream.StreamType
|
import org.schabi.newpipe.extractor.stream.StreamType
|
||||||
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||||
|
@ -57,6 +58,11 @@ class FeedDatabaseManager(context: Context) {
|
||||||
|
|
||||||
fun outdatedSubscriptions(outdatedThreshold: OffsetDateTime) = feedTable.getAllOutdated(outdatedThreshold)
|
fun outdatedSubscriptions(outdatedThreshold: OffsetDateTime) = feedTable.getAllOutdated(outdatedThreshold)
|
||||||
|
|
||||||
|
fun outdatedSubscriptionsWithNotificationMode(
|
||||||
|
outdatedThreshold: OffsetDateTime,
|
||||||
|
@NotificationMode notificationMode: Int
|
||||||
|
) = feedTable.getOutdatedWithNotificationMode(outdatedThreshold, notificationMode)
|
||||||
|
|
||||||
fun notLoadedCount(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable<Long> {
|
fun notLoadedCount(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable<Long> {
|
||||||
return when (groupId) {
|
return when (groupId) {
|
||||||
FeedGroupEntity.GROUP_ALL_ID -> feedTable.notLoadedCount()
|
FeedGroupEntity.GROUP_ALL_ID -> feedTable.notLoadedCount()
|
||||||
|
@ -72,6 +78,10 @@ class FeedDatabaseManager(context: Context) {
|
||||||
fun markAsOutdated(subscriptionId: Long) = feedTable
|
fun markAsOutdated(subscriptionId: Long) = feedTable
|
||||||
.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null))
|
.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null))
|
||||||
|
|
||||||
|
fun doesStreamExist(stream: StreamInfoItem): Boolean {
|
||||||
|
return streamTable.exists(stream.serviceId, stream.url)
|
||||||
|
}
|
||||||
|
|
||||||
fun upsertAll(
|
fun upsertAll(
|
||||||
subscriptionId: Long,
|
subscriptionId: Long,
|
||||||
items: List<StreamInfoItem>,
|
items: List<StreamInfoItem>,
|
||||||
|
|
|
@ -50,7 +50,6 @@ import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.xwray.groupie.GroupieAdapter
|
import com.xwray.groupie.GroupieAdapter
|
||||||
import com.xwray.groupie.Item
|
import com.xwray.groupie.Item
|
||||||
import com.xwray.groupie.OnAsyncUpdateListener
|
|
||||||
import com.xwray.groupie.OnItemClickListener
|
import com.xwray.groupie.OnItemClickListener
|
||||||
import com.xwray.groupie.OnItemLongClickListener
|
import com.xwray.groupie.OnItemLongClickListener
|
||||||
import icepick.State
|
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.AccountTerminatedException
|
||||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
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.extractor.utils.Utils.isNullOrEmpty
|
||||||
import org.schabi.newpipe.fragments.BaseStateFragment
|
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.animate
|
||||||
import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling
|
import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling
|
||||||
import org.schabi.newpipe.ktx.slideUp
|
import org.schabi.newpipe.ktx.slideUp
|
||||||
import org.schabi.newpipe.local.feed.item.StreamItem
|
import org.schabi.newpipe.local.feed.item.StreamItem
|
||||||
import org.schabi.newpipe.local.feed.service.FeedLoadService
|
import org.schabi.newpipe.local.feed.service.FeedLoadService
|
||||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
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.DeviceUtils
|
||||||
import org.schabi.newpipe.util.Localization
|
import org.schabi.newpipe.util.Localization
|
||||||
import org.schabi.newpipe.util.NavigationHelper
|
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.getGridSpanCountStreams
|
||||||
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
|
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.ArrayList
|
|
||||||
import java.util.function.Consumer
|
import java.util.function.Consumer
|
||||||
|
|
||||||
class FeedFragment : BaseStateFragment<FeedState>() {
|
class FeedFragment : BaseStateFragment<FeedState>() {
|
||||||
|
@ -143,7 +138,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||||
val factory = FeedViewModel.Factory(requireContext(), groupId)
|
val factory = FeedViewModel.Factory(requireContext(), groupId)
|
||||||
viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java)
|
viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java)
|
||||||
showPlayedItems = viewModel.getShowPlayedItemsFromPreferences()
|
showPlayedItems = viewModel.getShowPlayedItemsFromPreferences()
|
||||||
viewModel.stateLiveData.observe(viewLifecycleOwner, { it?.let(::handleResult) })
|
viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) }
|
||||||
|
|
||||||
groupAdapter = GroupieAdapter().apply {
|
groupAdapter = GroupieAdapter().apply {
|
||||||
setOnItemClickListener(listenerStreamItem)
|
setOnItemClickListener(listenerStreamItem)
|
||||||
|
@ -356,53 +351,12 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||||
feedBinding.loadingProgressBar.max = progressState.maxProgress
|
feedBinding.loadingProgressBar.max = progressState.maxProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showStreamDialog(item: StreamInfoItem) {
|
private fun showInfoItemDialog(item: StreamInfoItem) {
|
||||||
val context = context
|
val context = context
|
||||||
val activity: Activity? = getActivity()
|
val activity: Activity? = getActivity()
|
||||||
if (context == null || context.resources == null || activity == null) return
|
if (context == null || context.resources == null || activity == null) return
|
||||||
|
|
||||||
val entries = ArrayList<StreamDialogEntry>()
|
InfoItemDialog.Builder(activity, context, this, item).create().show()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener {
|
private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener {
|
||||||
|
@ -418,7 +372,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||||
|
|
||||||
override fun onItemLongClick(item: Item<*>, view: View): Boolean {
|
override fun onItemLongClick(item: Item<*>, view: View): Boolean {
|
||||||
if (item is StreamItem && !isRefreshing) {
|
if (item is StreamItem && !isRefreshing) {
|
||||||
showStreamDialog(item.streamWithState.stream.toStreamInfoItem())
|
showInfoItemDialog(item.streamWithState.stream.toStreamInfoItem())
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
@ -438,14 +392,11 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||||
// This need to be saved in a variable as the update occurs async
|
// This need to be saved in a variable as the update occurs async
|
||||||
val oldOldestSubscriptionUpdate = oldestSubscriptionUpdate
|
val oldOldestSubscriptionUpdate = oldestSubscriptionUpdate
|
||||||
|
|
||||||
groupAdapter.updateAsync(
|
groupAdapter.updateAsync(loadedState.items, false) {
|
||||||
loadedState.items, false,
|
oldOldestSubscriptionUpdate?.run {
|
||||||
OnAsyncUpdateListener {
|
highlightNewItemsAfter(oldOldestSubscriptionUpdate)
|
||||||
oldOldestSubscriptionUpdate?.run {
|
|
||||||
highlightNewItemsAfter(oldOldestSubscriptionUpdate)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
listState?.run {
|
listState?.run {
|
||||||
feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState)
|
feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState)
|
||||||
|
@ -497,8 +448,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||||
}.subscribeOn(Schedulers.io())
|
}.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(
|
.subscribe(
|
||||||
{
|
{ subscriptionEntity ->
|
||||||
subscriptionEntity ->
|
|
||||||
handleFeedNotAvailable(
|
handleFeedNotAvailable(
|
||||||
subscriptionEntity,
|
subscriptionEntity,
|
||||||
t.cause,
|
t.cause,
|
||||||
|
|
|
@ -56,7 +56,7 @@ class FeedViewModel(
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(Schedulers.io())
|
.observeOn(Schedulers.io())
|
||||||
.map { (event, showPlayedItems, notLoadedCount, oldestUpdate) ->
|
.map { (event, showPlayedItems, notLoadedCount, oldestUpdate) ->
|
||||||
var streamItems = if (event is SuccessResultEvent || event is IdleEvent)
|
val streamItems = if (event is SuccessResultEvent || event is IdleEvent)
|
||||||
feedDatabaseManager
|
feedDatabaseManager
|
||||||
.getStreams(groupId, showPlayedItems)
|
.getStreams(groupId, showPlayedItems)
|
||||||
.blockingGet(arrayListOf())
|
.blockingGet(arrayListOf())
|
||||||
|
|
|
@ -0,0 +1,145 @@
|
||||||
|
package org.schabi.newpipe.local.feed.notifications
|
||||||
|
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
import org.schabi.newpipe.local.feed.service.FeedUpdateInfo
|
||||||
|
import org.schabi.newpipe.util.Localization
|
||||||
|
import org.schabi.newpipe.util.NavigationHelper
|
||||||
|
import org.schabi.newpipe.util.PicassoHelper
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for everything related to show notifications about new streams to the user.
|
||||||
|
*/
|
||||||
|
class NotificationHelper(val context: Context) {
|
||||||
|
|
||||||
|
private val manager = context.getSystemService(
|
||||||
|
Context.NOTIFICATION_SERVICE
|
||||||
|
) as NotificationManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a notification about new streams from a single channel.
|
||||||
|
* Opening the notification will open the corresponding channel page.
|
||||||
|
*/
|
||||||
|
fun displayNewStreamsNotification(data: FeedUpdateInfo) {
|
||||||
|
val newStreams: List<StreamInfoItem> = data.newStreams
|
||||||
|
val summary = context.resources.getQuantityString(
|
||||||
|
R.plurals.new_streams, newStreams.size, newStreams.size
|
||||||
|
)
|
||||||
|
val builder = NotificationCompat.Builder(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.streams_notification_channel_id)
|
||||||
|
)
|
||||||
|
.setContentTitle(Localization.concatenateStrings(data.name, summary))
|
||||||
|
.setContentText(
|
||||||
|
data.listInfo.relatedItems.joinToString(
|
||||||
|
context.getString(R.string.enumeration_comma)
|
||||||
|
) { x -> x.name }
|
||||||
|
)
|
||||||
|
.setNumber(newStreams.size)
|
||||||
|
.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||||
|
.setColor(ContextCompat.getColor(context, R.color.ic_launcher_background))
|
||||||
|
.setColorized(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
|
||||||
|
|
||||||
|
// Build style
|
||||||
|
val style = NotificationCompat.InboxStyle()
|
||||||
|
newStreams.forEach { style.addLine(it.name) }
|
||||||
|
style.setSummaryText(summary)
|
||||||
|
style.setBigContentTitle(data.name)
|
||||||
|
builder.setStyle(style)
|
||||||
|
|
||||||
|
// open the channel page when clicking on the notification
|
||||||
|
builder.setContentIntent(
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
data.pseudoId,
|
||||||
|
NavigationHelper
|
||||||
|
.getChannelIntent(context, data.listInfo.serviceId, data.listInfo.url)
|
||||||
|
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
else
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
PicassoHelper.loadNotificationIcon(data.avatarUrl) { bitmap ->
|
||||||
|
bitmap?.let { builder.setLargeIcon(it) } // set only if != null
|
||||||
|
manager.notify(data.pseudoId, builder.build())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Check whether notifications are enabled on the device.
|
||||||
|
* Users can disable them via the system settings for a single app.
|
||||||
|
* If this is the case, the app cannot create any notifications
|
||||||
|
* and display them to the user.
|
||||||
|
* <br>
|
||||||
|
* On Android 26 and above, notification channels are used by NewPipe.
|
||||||
|
* These can be configured by the user, too.
|
||||||
|
* The notification channel for new streams is also checked by this method.
|
||||||
|
*
|
||||||
|
* @param context Context
|
||||||
|
* @return <code>true</code> if notifications are allowed and can be displayed;
|
||||||
|
* <code>false</code> otherwise
|
||||||
|
*/
|
||||||
|
fun areNotificationsEnabledOnDevice(context: Context): Boolean {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channelId = context.getString(R.string.streams_notification_channel_id)
|
||||||
|
val manager = context.getSystemService(
|
||||||
|
Context.NOTIFICATION_SERVICE
|
||||||
|
) as NotificationManager
|
||||||
|
val enabled = manager.areNotificationsEnabled()
|
||||||
|
val channel = manager.getNotificationChannel(channelId)
|
||||||
|
val importance = channel?.importance
|
||||||
|
enabled && channel != null && importance != NotificationManager.IMPORTANCE_NONE
|
||||||
|
} else {
|
||||||
|
NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the user enabled the notifications for new streams in the app settings.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun areNewStreamsNotificationsEnabled(context: Context): Boolean {
|
||||||
|
return (
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
.getBoolean(context.getString(R.string.enable_streams_notifications), false) &&
|
||||||
|
areNotificationsEnabledOnDevice(context)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the system's notification settings for NewPipe on Android Oreo (API 26) and later.
|
||||||
|
* Open the system's app settings for NewPipe on previous Android versions.
|
||||||
|
*/
|
||||||
|
fun openNewPipeSystemNotificationSettings(context: Context) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||||
|
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
context.startActivity(intent)
|
||||||
|
} else {
|
||||||
|
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||||
|
intent.data = Uri.parse("package:" + context.packageName)
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,170 @@
|
||||||
|
package org.schabi.newpipe.local.feed.notifications
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.ForegroundInfo
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.PeriodicWorkRequest
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import androidx.work.rxjava3.RxWorker
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import org.schabi.newpipe.App
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.error.ErrorInfo
|
||||||
|
import org.schabi.newpipe.error.ErrorUtil
|
||||||
|
import org.schabi.newpipe.error.UserAction
|
||||||
|
import org.schabi.newpipe.local.feed.service.FeedLoadManager
|
||||||
|
import org.schabi.newpipe.local.feed.service.FeedLoadService
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Worker which checks for new streams of subscribed channels
|
||||||
|
* in intervals which can be set by the user in the settings.
|
||||||
|
*/
|
||||||
|
class NotificationWorker(
|
||||||
|
appContext: Context,
|
||||||
|
workerParams: WorkerParameters,
|
||||||
|
) : RxWorker(appContext, workerParams) {
|
||||||
|
|
||||||
|
private val notificationHelper by lazy {
|
||||||
|
NotificationHelper(appContext)
|
||||||
|
}
|
||||||
|
private val feedLoadManager = FeedLoadManager(appContext)
|
||||||
|
|
||||||
|
override fun createWork(): Single<Result> = if (areNotificationsEnabled(applicationContext)) {
|
||||||
|
feedLoadManager.startLoading(
|
||||||
|
ignoreOutdatedThreshold = true,
|
||||||
|
groupId = FeedLoadManager.GROUP_NOTIFICATION_ENABLED
|
||||||
|
)
|
||||||
|
.doOnSubscribe { showLoadingFeedForegroundNotification() }
|
||||||
|
.map { feed ->
|
||||||
|
// filter out feedUpdateInfo items (i.e. channels) with nothing new
|
||||||
|
feed.mapNotNull {
|
||||||
|
it.value?.takeIf { feedUpdateInfo ->
|
||||||
|
feedUpdateInfo.newStreams.isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.observeOn(AndroidSchedulers.mainThread()) // Picasso requires calls from main thread
|
||||||
|
.map { feedUpdateInfoList ->
|
||||||
|
// display notifications for each feedUpdateInfo (i.e. channel)
|
||||||
|
feedUpdateInfoList.forEach { feedUpdateInfo ->
|
||||||
|
notificationHelper.displayNewStreamsNotification(feedUpdateInfo)
|
||||||
|
}
|
||||||
|
return@map Result.success()
|
||||||
|
}
|
||||||
|
.doOnError { throwable ->
|
||||||
|
Log.e(TAG, "Error while displaying streams notifications", throwable)
|
||||||
|
ErrorUtil.createNotification(
|
||||||
|
applicationContext,
|
||||||
|
ErrorInfo(throwable, UserAction.NEW_STREAMS_NOTIFICATIONS, "main worker")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onErrorReturnItem(Result.failure())
|
||||||
|
} else {
|
||||||
|
// the user can disable streams notifications in the device's app settings
|
||||||
|
Single.just(Result.success())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showLoadingFeedForegroundNotification() {
|
||||||
|
val notification = NotificationCompat.Builder(
|
||||||
|
applicationContext,
|
||||||
|
applicationContext.getString(R.string.notification_channel_id)
|
||||||
|
).setOngoing(true)
|
||||||
|
.setProgress(-1, -1, true)
|
||||||
|
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setContentTitle(applicationContext.getString(R.string.feed_notification_loading))
|
||||||
|
.build()
|
||||||
|
setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private val TAG = NotificationWorker::class.java.simpleName
|
||||||
|
private const val WORK_TAG = App.PACKAGE_NAME + "_streams_notifications"
|
||||||
|
|
||||||
|
private fun areNotificationsEnabled(context: Context) =
|
||||||
|
NotificationHelper.areNewStreamsNotificationsEnabled(context) &&
|
||||||
|
NotificationHelper.areNotificationsEnabledOnDevice(context)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules a task for the [NotificationWorker]
|
||||||
|
* if the (device and in-app) notifications are enabled,
|
||||||
|
* otherwise [cancel]s all scheduled tasks.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun initialize(context: Context) {
|
||||||
|
if (areNotificationsEnabled(context)) {
|
||||||
|
schedule(context)
|
||||||
|
} else {
|
||||||
|
cancel(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param context the context to use
|
||||||
|
* @param options configuration options for the scheduler
|
||||||
|
* @param force Force the scheduler to use the new options
|
||||||
|
* by replacing the previously used worker.
|
||||||
|
*/
|
||||||
|
fun schedule(context: Context, options: ScheduleOptions, force: Boolean = false) {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(
|
||||||
|
if (options.isRequireNonMeteredNetwork) {
|
||||||
|
NetworkType.UNMETERED
|
||||||
|
} else {
|
||||||
|
NetworkType.CONNECTED
|
||||||
|
}
|
||||||
|
).build()
|
||||||
|
|
||||||
|
val request = PeriodicWorkRequest.Builder(
|
||||||
|
NotificationWorker::class.java,
|
||||||
|
options.interval,
|
||||||
|
TimeUnit.MILLISECONDS
|
||||||
|
).setConstraints(constraints)
|
||||||
|
.addTag(WORK_TAG)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.enqueueUniquePeriodicWork(
|
||||||
|
WORK_TAG,
|
||||||
|
if (force) {
|
||||||
|
ExistingPeriodicWorkPolicy.REPLACE
|
||||||
|
} else {
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP
|
||||||
|
},
|
||||||
|
request
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun schedule(context: Context) = schedule(context, ScheduleOptions.from(context))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for new streams immediately
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun runNow(context: Context) {
|
||||||
|
val request = OneTimeWorkRequestBuilder<NotificationWorker>()
|
||||||
|
.addTag(WORK_TAG)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context).enqueue(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels all current work related to the [NotificationWorker].
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun cancel(context: Context) {
|
||||||
|
WorkManager.getInstance(context).cancelAllWorkByTag(WORK_TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
package org.schabi.newpipe.local.feed.notifications
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Information for the Scheduler which checks for new streams.
|
||||||
|
* See [NotificationWorker]
|
||||||
|
*/
|
||||||
|
data class ScheduleOptions(
|
||||||
|
val interval: Long,
|
||||||
|
val isRequireNonMeteredNetwork: Boolean
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun from(context: Context): ScheduleOptions {
|
||||||
|
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
return ScheduleOptions(
|
||||||
|
interval = TimeUnit.SECONDS.toMillis(
|
||||||
|
preferences.getString(
|
||||||
|
context.getString(R.string.streams_notifications_interval_key),
|
||||||
|
null
|
||||||
|
)?.toLongOrNull() ?: context.getString(
|
||||||
|
R.string.streams_notifications_interval_default
|
||||||
|
).toLong()
|
||||||
|
),
|
||||||
|
isRequireNonMeteredNetwork = preferences.getString(
|
||||||
|
context.getString(R.string.streams_notifications_network_key),
|
||||||
|
context.getString(R.string.streams_notifications_network_default)
|
||||||
|
) == context.getString(R.string.streams_notifications_network_wifi)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,270 @@
|
||||||
|
package org.schabi.newpipe.local.feed.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.core.Completable
|
||||||
|
import io.reactivex.rxjava3.core.Flowable
|
||||||
|
import io.reactivex.rxjava3.core.Notification
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import io.reactivex.rxjava3.functions.Consumer
|
||||||
|
import io.reactivex.rxjava3.processors.PublishProcessor
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
|
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||||
|
import org.schabi.newpipe.extractor.ListInfo
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||||
|
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||||
|
import org.schabi.newpipe.util.ExtractorHelper
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
class FeedLoadManager(private val context: Context) {
|
||||||
|
|
||||||
|
private val subscriptionManager = SubscriptionManager(context)
|
||||||
|
private val feedDatabaseManager = FeedDatabaseManager(context)
|
||||||
|
|
||||||
|
private val notificationUpdater = PublishProcessor.create<String>()
|
||||||
|
private val currentProgress = AtomicInteger(-1)
|
||||||
|
private val maxProgress = AtomicInteger(-1)
|
||||||
|
private val cancelSignal = AtomicBoolean()
|
||||||
|
private val feedResultsHolder = FeedResultsHolder()
|
||||||
|
|
||||||
|
val notification: Flowable<FeedLoadState> = notificationUpdater.map { description ->
|
||||||
|
FeedLoadState(description, maxProgress.get(), currentProgress.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start checking for new streams of a subscription group.
|
||||||
|
* @param groupId The ID of the subscription group to load. When using
|
||||||
|
* [FeedGroupEntity.GROUP_ALL_ID], all subscriptions are loaded. When using
|
||||||
|
* [GROUP_NOTIFICATION_ENABLED], only subscriptions with enabled notifications for new streams
|
||||||
|
* are loaded. Using an id of a group created by the user results in that specific group to be
|
||||||
|
* loaded.
|
||||||
|
* @param ignoreOutdatedThreshold When `false`, only subscriptions which have not been updated
|
||||||
|
* within the `feed_update_threshold` are checked for updates. This threshold can be set by
|
||||||
|
* the user in the app settings. When `true`, all subscriptions are checked for new streams.
|
||||||
|
*/
|
||||||
|
fun startLoading(
|
||||||
|
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
|
||||||
|
ignoreOutdatedThreshold: Boolean = false,
|
||||||
|
): Single<List<Notification<FeedUpdateInfo>>> {
|
||||||
|
val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
val useFeedExtractor = defaultSharedPreferences.getBoolean(
|
||||||
|
context.getString(R.string.feed_use_dedicated_fetch_method_key),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
val outdatedThreshold = if (ignoreOutdatedThreshold) {
|
||||||
|
OffsetDateTime.now(ZoneOffset.UTC)
|
||||||
|
} else {
|
||||||
|
val thresholdOutdatedSeconds = (
|
||||||
|
defaultSharedPreferences.getString(
|
||||||
|
context.getString(R.string.feed_update_threshold_key),
|
||||||
|
context.getString(R.string.feed_update_threshold_default_value)
|
||||||
|
) ?: context.getString(R.string.feed_update_threshold_default_value)
|
||||||
|
).toInt()
|
||||||
|
OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* subscriptions which have not been updated within the feed updated threshold
|
||||||
|
*/
|
||||||
|
val outdatedSubscriptions = when (groupId) {
|
||||||
|
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
|
||||||
|
GROUP_NOTIFICATION_ENABLED -> feedDatabaseManager.outdatedSubscriptionsWithNotificationMode(
|
||||||
|
outdatedThreshold, NotificationMode.ENABLED
|
||||||
|
)
|
||||||
|
else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outdatedSubscriptions
|
||||||
|
.take(1)
|
||||||
|
.doOnNext {
|
||||||
|
currentProgress.set(0)
|
||||||
|
maxProgress.set(it.size)
|
||||||
|
}
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.doOnNext {
|
||||||
|
notificationUpdater.onNext("")
|
||||||
|
broadcastProgress()
|
||||||
|
}
|
||||||
|
.observeOn(Schedulers.io())
|
||||||
|
.flatMap { Flowable.fromIterable(it) }
|
||||||
|
.takeWhile { !cancelSignal.get() }
|
||||||
|
.parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2)
|
||||||
|
.runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
|
||||||
|
.filter { !cancelSignal.get() }
|
||||||
|
.map { subscriptionEntity ->
|
||||||
|
var error: Throwable? = null
|
||||||
|
try {
|
||||||
|
// check for and load new streams
|
||||||
|
// either by using the dedicated feed method or by getting the channel info
|
||||||
|
val listInfo = if (useFeedExtractor) {
|
||||||
|
ExtractorHelper
|
||||||
|
.getFeedInfoFallbackToChannelInfo(
|
||||||
|
subscriptionEntity.serviceId,
|
||||||
|
subscriptionEntity.url
|
||||||
|
)
|
||||||
|
.onErrorReturn {
|
||||||
|
error = it // store error, otherwise wrapped into RuntimeException
|
||||||
|
throw it
|
||||||
|
}
|
||||||
|
.blockingGet()
|
||||||
|
} else {
|
||||||
|
ExtractorHelper
|
||||||
|
.getChannelInfo(
|
||||||
|
subscriptionEntity.serviceId,
|
||||||
|
subscriptionEntity.url,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
.onErrorReturn {
|
||||||
|
error = it // store error, otherwise wrapped into RuntimeException
|
||||||
|
throw it
|
||||||
|
}
|
||||||
|
.blockingGet()
|
||||||
|
} as ListInfo<StreamInfoItem>
|
||||||
|
|
||||||
|
return@map Notification.createOnNext(
|
||||||
|
FeedUpdateInfo(
|
||||||
|
subscriptionEntity,
|
||||||
|
listInfo
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
if (error == null) {
|
||||||
|
// do this to prevent blockingGet() from wrapping into RuntimeException
|
||||||
|
error = e
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
|
||||||
|
val wrapper =
|
||||||
|
FeedLoadService.RequestException(subscriptionEntity.uid, request, error!!)
|
||||||
|
return@map Notification.createOnError<FeedUpdateInfo>(wrapper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sequential()
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.doOnNext(NotificationConsumer())
|
||||||
|
.observeOn(Schedulers.io())
|
||||||
|
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
||||||
|
.doOnNext(DatabaseConsumer())
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.toList()
|
||||||
|
.flatMap { x -> postProcessFeed().toSingleDefault(x.flatten()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
cancelSignal.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun broadcastProgress() {
|
||||||
|
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(currentProgress.get(), maxProgress.get()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the feed and the stream tables small
|
||||||
|
* to reduce loading times when trying to display the feed.
|
||||||
|
* <br>
|
||||||
|
* Remove streams from the feed which are older than [FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE].
|
||||||
|
* Remove streams from the database which are not linked / used by any table.
|
||||||
|
*/
|
||||||
|
private fun postProcessFeed() = Completable.fromRunnable {
|
||||||
|
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message))
|
||||||
|
feedDatabaseManager.removeOrphansOrOlderStreams()
|
||||||
|
|
||||||
|
FeedEventManager.postEvent(FeedEventManager.Event.SuccessResultEvent(feedResultsHolder.itemsErrors))
|
||||||
|
}.doOnSubscribe {
|
||||||
|
currentProgress.set(-1)
|
||||||
|
maxProgress.set(-1)
|
||||||
|
|
||||||
|
notificationUpdater.onNext(context.getString(R.string.feed_processing_message))
|
||||||
|
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message))
|
||||||
|
}.subscribeOn(Schedulers.io())
|
||||||
|
|
||||||
|
private inner class NotificationConsumer : Consumer<Notification<FeedUpdateInfo>> {
|
||||||
|
override fun accept(item: Notification<FeedUpdateInfo>) {
|
||||||
|
currentProgress.incrementAndGet()
|
||||||
|
notificationUpdater.onNext(item.value?.name.orEmpty())
|
||||||
|
|
||||||
|
broadcastProgress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class DatabaseConsumer : Consumer<List<Notification<FeedUpdateInfo>>> {
|
||||||
|
|
||||||
|
override fun accept(list: List<Notification<FeedUpdateInfo>>) {
|
||||||
|
feedDatabaseManager.database().runInTransaction {
|
||||||
|
for (notification in list) {
|
||||||
|
when {
|
||||||
|
notification.isOnNext -> {
|
||||||
|
val subscriptionId = notification.value!!.uid
|
||||||
|
val info = notification.value!!.listInfo
|
||||||
|
|
||||||
|
notification.value!!.newStreams = filterNewStreams(
|
||||||
|
notification.value!!.listInfo.relatedItems
|
||||||
|
)
|
||||||
|
|
||||||
|
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
|
||||||
|
subscriptionManager.updateFromInfo(subscriptionId, info)
|
||||||
|
|
||||||
|
if (info.errors.isNotEmpty()) {
|
||||||
|
feedResultsHolder.addErrors(
|
||||||
|
FeedLoadService.RequestException.wrapList(
|
||||||
|
subscriptionId,
|
||||||
|
info
|
||||||
|
)
|
||||||
|
)
|
||||||
|
feedDatabaseManager.markAsOutdated(subscriptionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notification.isOnError -> {
|
||||||
|
val error = notification.error
|
||||||
|
feedResultsHolder.addError(error!!)
|
||||||
|
|
||||||
|
if (error is FeedLoadService.RequestException) {
|
||||||
|
feedDatabaseManager.markAsOutdated(error.subscriptionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filterNewStreams(list: List<StreamInfoItem>): List<StreamInfoItem> {
|
||||||
|
return list.filter {
|
||||||
|
!feedDatabaseManager.doesStreamExist(it) &&
|
||||||
|
it.uploadDate != null &&
|
||||||
|
// Streams older than this date are automatically removed from the feed.
|
||||||
|
// Therefore, streams which are not in the database,
|
||||||
|
// but older than this date, are considered old.
|
||||||
|
it.uploadDate!!.offsetDateTime().isAfter(
|
||||||
|
FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant used to check for updates of subscriptions with [NotificationMode.ENABLED].
|
||||||
|
*/
|
||||||
|
const val GROUP_NOTIFICATION_ENABLED = -2L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many extractions will be running in parallel.
|
||||||
|
*/
|
||||||
|
private const val PARALLEL_EXTRACTIONS = 6
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number of items to buffer to mass-insert in the database.
|
||||||
|
*/
|
||||||
|
private const val BUFFER_COUNT_BEFORE_INSERT = 20
|
||||||
|
}
|
||||||
|
}
|
|
@ -31,41 +31,24 @@ import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.core.app.ServiceCompat
|
import androidx.core.app.ServiceCompat
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
import io.reactivex.rxjava3.core.Flowable
|
import io.reactivex.rxjava3.core.Flowable
|
||||||
import io.reactivex.rxjava3.core.Notification
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
import io.reactivex.rxjava3.core.Single
|
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
|
||||||
import io.reactivex.rxjava3.functions.Consumer
|
|
||||||
import io.reactivex.rxjava3.functions.Function
|
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.App
|
||||||
import org.schabi.newpipe.MainActivity.DEBUG
|
import org.schabi.newpipe.MainActivity.DEBUG
|
||||||
import org.schabi.newpipe.R
|
import org.schabi.newpipe.R
|
||||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
import org.schabi.newpipe.extractor.ListInfo
|
import org.schabi.newpipe.extractor.ListInfo
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
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.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.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.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
|
||||||
|
|
||||||
class FeedLoadService : Service() {
|
class FeedLoadService : Service() {
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = FeedLoadService::class.java.simpleName
|
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"
|
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
|
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"
|
const val EXTRA_GROUP_ID: String = "FeedLoadService.EXTRA_GROUP_ID"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var loadingSubscription: Subscription? = null
|
private var loadingDisposable: Disposable? = null
|
||||||
private lateinit var subscriptionManager: SubscriptionManager
|
private var notificationDisposable: Disposable? = null
|
||||||
|
|
||||||
private lateinit var feedDatabaseManager: FeedDatabaseManager
|
private lateinit var feedLoadManager: FeedLoadManager
|
||||||
private lateinit var feedResultsHolder: ResultsHolder
|
|
||||||
|
|
||||||
private var disposables = CompositeDisposable()
|
|
||||||
private var notificationUpdater = PublishProcessor.create<String>()
|
|
||||||
|
|
||||||
// /////////////////////////////////////////////////////////////////////////
|
// /////////////////////////////////////////////////////////////////////////
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
|
@ -101,8 +70,7 @@ class FeedLoadService : Service() {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
subscriptionManager = SubscriptionManager(this)
|
feedLoadManager = FeedLoadManager(this)
|
||||||
feedDatabaseManager = FeedDatabaseManager(this)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
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
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
setupNotification()
|
setupNotification()
|
||||||
setupBroadcastReceiver()
|
setupBroadcastReceiver()
|
||||||
val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
|
|
||||||
|
|
||||||
val groupId = intent.getLongExtra(EXTRA_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID)
|
val groupId = intent.getLongExtra(EXTRA_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID)
|
||||||
val useFeedExtractor = defaultSharedPreferences
|
loadingDisposable = feedLoadManager.startLoading(groupId)
|
||||||
.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false)
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.doOnSubscribe {
|
||||||
val thresholdOutdatedSecondsString = defaultSharedPreferences
|
startForeground(NOTIFICATION_ID, notificationBuilder.build())
|
||||||
.getString(getString(R.string.feed_update_threshold_key), getString(R.string.feed_update_threshold_default_value))
|
}
|
||||||
val thresholdOutdatedSeconds = thresholdOutdatedSecondsString!!.toInt()
|
.subscribe { _, error ->
|
||||||
|
// There seems to be a bug in the kotlin plugin as it tells you when
|
||||||
startLoading(groupId, useFeedExtractor, thresholdOutdatedSeconds)
|
// 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
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun disposeAll() {
|
private fun disposeAll() {
|
||||||
unregisterReceiver(broadcastReceiver)
|
unregisterReceiver(broadcastReceiver)
|
||||||
|
loadingDisposable?.dispose()
|
||||||
loadingSubscription?.cancel()
|
notificationDisposable?.dispose()
|
||||||
loadingSubscription = null
|
|
||||||
|
|
||||||
disposables.dispose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopService() {
|
private fun stopService() {
|
||||||
disposeAll()
|
disposeAll()
|
||||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||||
notificationManager.cancel(NOTIFICATION_ID)
|
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -171,182 +144,6 @@ class FeedLoadService : Service() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startLoading(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, useFeedExtractor: Boolean, thresholdOutdatedSeconds: Int) {
|
|
||||||
feedResultsHolder = ResultsHolder()
|
|
||||||
|
|
||||||
val outdatedThreshold = OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong())
|
|
||||||
|
|
||||||
val subscriptions = when (groupId) {
|
|
||||||
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
|
|
||||||
else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriptions
|
|
||||||
.take(1)
|
|
||||||
.doOnNext {
|
|
||||||
currentProgress.set(0)
|
|
||||||
maxProgress.set(it.size)
|
|
||||||
}
|
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.doOnNext {
|
|
||||||
startForeground(NOTIFICATION_ID, notificationBuilder.build())
|
|
||||||
updateNotificationProgress(null)
|
|
||||||
broadcastProgress()
|
|
||||||
}
|
|
||||||
.observeOn(Schedulers.io())
|
|
||||||
.flatMap { Flowable.fromIterable(it) }
|
|
||||||
.takeWhile { !cancelSignal.get() }
|
|
||||||
.parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2)
|
|
||||||
.runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
|
|
||||||
.filter { !cancelSignal.get() }
|
|
||||||
.map { subscriptionEntity ->
|
|
||||||
var error: Throwable? = null
|
|
||||||
try {
|
|
||||||
val listInfo = if (useFeedExtractor) {
|
|
||||||
ExtractorHelper
|
|
||||||
.getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url)
|
|
||||||
.onErrorReturn {
|
|
||||||
error = it // store error, otherwise wrapped into RuntimeException
|
|
||||||
throw it
|
|
||||||
}
|
|
||||||
.blockingGet()
|
|
||||||
} else {
|
|
||||||
ExtractorHelper
|
|
||||||
.getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true)
|
|
||||||
.onErrorReturn {
|
|
||||||
error = it // store error, otherwise wrapped into RuntimeException
|
|
||||||
throw it
|
|
||||||
}
|
|
||||||
.blockingGet()
|
|
||||||
} as ListInfo<StreamInfoItem>
|
|
||||||
|
|
||||||
return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo))
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
if (error == null) {
|
|
||||||
// do this to prevent blockingGet() from wrapping into RuntimeException
|
|
||||||
error = e
|
|
||||||
}
|
|
||||||
|
|
||||||
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
|
|
||||||
val wrapper = RequestException(subscriptionEntity.uid, request, error!!)
|
|
||||||
return@map Notification.createOnError<Pair<Long, ListInfo<StreamInfoItem>>>(wrapper)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sequential()
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.doOnNext(notificationsConsumer)
|
|
||||||
.observeOn(Schedulers.io())
|
|
||||||
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
|
||||||
.doOnNext(databaseConsumer)
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(resultSubscriber)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun broadcastProgress() {
|
|
||||||
postEvent(ProgressEvent(currentProgress.get(), maxProgress.get()))
|
|
||||||
}
|
|
||||||
|
|
||||||
private val resultSubscriber
|
|
||||||
get() = object : Subscriber<List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>> {
|
|
||||||
|
|
||||||
override fun onSubscribe(s: Subscription) {
|
|
||||||
loadingSubscription = s
|
|
||||||
s.request(java.lang.Long.MAX_VALUE)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNext(notification: List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>) {
|
|
||||||
if (DEBUG) Log.v(TAG, "onNext() → $notification")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(error: Throwable) {
|
|
||||||
handleError(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onComplete() {
|
|
||||||
if (maxProgress.get() == 0) {
|
|
||||||
postEvent(FeedEventManager.Event.IdleEvent)
|
|
||||||
stopService()
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentProgress.set(-1)
|
|
||||||
maxProgress.set(-1)
|
|
||||||
|
|
||||||
notificationUpdater.onNext(getString(R.string.feed_processing_message))
|
|
||||||
postEvent(ProgressEvent(R.string.feed_processing_message))
|
|
||||||
|
|
||||||
disposables.add(
|
|
||||||
Single
|
|
||||||
.fromCallable {
|
|
||||||
feedResultsHolder.ready()
|
|
||||||
|
|
||||||
postEvent(ProgressEvent(R.string.feed_processing_message))
|
|
||||||
feedDatabaseManager.removeOrphansOrOlderStreams()
|
|
||||||
|
|
||||||
postEvent(SuccessResultEvent(feedResultsHolder.itemsErrors))
|
|
||||||
true
|
|
||||||
}
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe { _, throwable ->
|
|
||||||
// There seems to be a bug in the kotlin plugin as it tells you when
|
|
||||||
// building that this can't be null:
|
|
||||||
// "Condition 'throwable != null' is always 'true'"
|
|
||||||
// However it can indeed be null
|
|
||||||
// The suppression may be removed in further versions
|
|
||||||
@Suppress("SENSELESS_COMPARISON")
|
|
||||||
if (throwable != null) {
|
|
||||||
Log.e(TAG, "Error while storing result", throwable)
|
|
||||||
handleError(throwable)
|
|
||||||
return@subscribe
|
|
||||||
}
|
|
||||||
stopService()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val databaseConsumer: Consumer<List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>>
|
|
||||||
get() = Consumer {
|
|
||||||
feedDatabaseManager.database().runInTransaction {
|
|
||||||
for (notification in it) {
|
|
||||||
|
|
||||||
if (notification.isOnNext) {
|
|
||||||
val subscriptionId = notification.value!!.first
|
|
||||||
val info = notification.value!!.second
|
|
||||||
|
|
||||||
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
|
|
||||||
subscriptionManager.updateFromInfo(subscriptionId, info)
|
|
||||||
|
|
||||||
if (info.errors.isNotEmpty()) {
|
|
||||||
feedResultsHolder.addErrors(RequestException.wrapList(subscriptionId, info))
|
|
||||||
feedDatabaseManager.markAsOutdated(subscriptionId)
|
|
||||||
}
|
|
||||||
} else if (notification.isOnError) {
|
|
||||||
val error = notification.error!!
|
|
||||||
feedResultsHolder.addError(error)
|
|
||||||
|
|
||||||
if (error is RequestException) {
|
|
||||||
feedDatabaseManager.markAsOutdated(error.subscriptionId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val notificationsConsumer: Consumer<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>
|
|
||||||
get() = Consumer { onItemCompleted(it.value?.second?.name) }
|
|
||||||
|
|
||||||
private fun onItemCompleted(updateDescription: String?) {
|
|
||||||
currentProgress.incrementAndGet()
|
|
||||||
notificationUpdater.onNext(updateDescription ?: "")
|
|
||||||
|
|
||||||
broadcastProgress()
|
|
||||||
}
|
|
||||||
|
|
||||||
// /////////////////////////////////////////////////////////////////////////
|
// /////////////////////////////////////////////////////////////////////////
|
||||||
// Notification
|
// Notification
|
||||||
// /////////////////////////////////////////////////////////////////////////
|
// /////////////////////////////////////////////////////////////////////////
|
||||||
|
@ -354,13 +151,12 @@ class FeedLoadService : Service() {
|
||||||
private lateinit var notificationManager: NotificationManagerCompat
|
private lateinit var notificationManager: NotificationManagerCompat
|
||||||
private lateinit var notificationBuilder: NotificationCompat.Builder
|
private lateinit var notificationBuilder: NotificationCompat.Builder
|
||||||
|
|
||||||
private var currentProgress = AtomicInteger(-1)
|
|
||||||
private var maxProgress = AtomicInteger(-1)
|
|
||||||
|
|
||||||
private fun createNotification(): NotificationCompat.Builder {
|
private fun createNotification(): NotificationCompat.Builder {
|
||||||
val cancelActionIntent = PendingIntent.getBroadcast(
|
val cancelActionIntent = PendingIntent.getBroadcast(
|
||||||
this,
|
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))
|
return NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
|
||||||
|
@ -376,33 +172,36 @@ class FeedLoadService : Service() {
|
||||||
notificationManager = NotificationManagerCompat.from(this)
|
notificationManager = NotificationManagerCompat.from(this)
|
||||||
notificationBuilder = createNotification()
|
notificationBuilder = createNotification()
|
||||||
|
|
||||||
val throttleAfterFirstEmission = Function { flow: Flowable<String> ->
|
val throttleAfterFirstEmission = Function { flow: Flowable<FeedLoadState> ->
|
||||||
flow.take(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS))
|
flow.take(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS))
|
||||||
}
|
}
|
||||||
|
|
||||||
disposables.add(
|
notificationDisposable = feedLoadManager.notification
|
||||||
notificationUpdater
|
.publish(throttleAfterFirstEmission)
|
||||||
.publish(throttleAfterFirstEmission)
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.doOnTerminate { notificationManager.cancel(NOTIFICATION_ID) }
|
||||||
.subscribe(this::updateNotificationProgress)
|
.subscribe(this::updateNotificationProgress)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateNotificationProgress(updateDescription: String?) {
|
private fun updateNotificationProgress(state: FeedLoadState) {
|
||||||
notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1)
|
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 (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null)
|
||||||
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
|
if (state.updateDescription.isNotEmpty()) notificationBuilder.setContentText(state.updateDescription)
|
||||||
notificationBuilder.setContentText(updateDescription)
|
notificationBuilder.setContentText(state.updateDescription)
|
||||||
} else {
|
} else {
|
||||||
val progressText = this.currentProgress.toString() + "/" + maxProgress
|
val progressText = state.currentProgress.toString() + "/" + state.maxProgress
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
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 {
|
} else {
|
||||||
notificationBuilder.setContentInfo(progressText)
|
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 lateinit var broadcastReceiver: BroadcastReceiver
|
||||||
private val cancelSignal = AtomicBoolean()
|
|
||||||
|
|
||||||
private fun setupBroadcastReceiver() {
|
private fun setupBroadcastReceiver() {
|
||||||
broadcastReceiver = object : BroadcastReceiver() {
|
broadcastReceiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
if (intent?.action == ACTION_CANCEL) {
|
if (intent?.action == ACTION_CANCEL) {
|
||||||
cancelSignal.set(true)
|
feedLoadManager.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -435,29 +233,4 @@ class FeedLoadService : Service() {
|
||||||
postEvent(ErrorResultEvent(error))
|
postEvent(ErrorResultEvent(error))
|
||||||
stopService()
|
stopService()
|
||||||
}
|
}
|
||||||
|
|
||||||
// /////////////////////////////////////////////////////////////////////////
|
|
||||||
// Results Holder
|
|
||||||
// /////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
class ResultsHolder {
|
|
||||||
/**
|
|
||||||
* List of errors that may have happen during loading.
|
|
||||||
*/
|
|
||||||
internal lateinit var itemsErrors: List<Throwable>
|
|
||||||
|
|
||||||
private val itemsErrorsHolder: MutableList<Throwable> = ArrayList()
|
|
||||||
|
|
||||||
fun addError(error: Throwable) {
|
|
||||||
itemsErrorsHolder.add(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addErrors(errors: List<Throwable>) {
|
|
||||||
itemsErrorsHolder.addAll(errors)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ready() {
|
|
||||||
itemsErrors = itemsErrorsHolder.toList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
package org.schabi.newpipe.local.feed.service
|
||||||
|
|
||||||
|
data class FeedLoadState(
|
||||||
|
val updateDescription: String,
|
||||||
|
val maxProgress: Int,
|
||||||
|
val currentProgress: Int,
|
||||||
|
)
|
|
@ -0,0 +1,19 @@
|
||||||
|
package org.schabi.newpipe.local.feed.service
|
||||||
|
|
||||||
|
class FeedResultsHolder {
|
||||||
|
/**
|
||||||
|
* List of errors that may have happen during loading.
|
||||||
|
*/
|
||||||
|
val itemsErrors: List<Throwable>
|
||||||
|
get() = itemsErrorsHolder
|
||||||
|
|
||||||
|
private val itemsErrorsHolder: MutableList<Throwable> = ArrayList()
|
||||||
|
|
||||||
|
fun addError(error: Throwable) {
|
||||||
|
itemsErrorsHolder.add(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addErrors(errors: List<Throwable>) {
|
||||||
|
itemsErrorsHolder.addAll(errors)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
package org.schabi.newpipe.local.feed.service
|
||||||
|
|
||||||
|
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||||
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||||
|
import org.schabi.newpipe.extractor.ListInfo
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
|
||||||
|
data class FeedUpdateInfo(
|
||||||
|
val uid: Long,
|
||||||
|
@NotificationMode
|
||||||
|
val notificationMode: Int,
|
||||||
|
val name: String,
|
||||||
|
val avatarUrl: String,
|
||||||
|
val listInfo: ListInfo<StreamInfoItem>,
|
||||||
|
) {
|
||||||
|
constructor(
|
||||||
|
subscription: SubscriptionEntity,
|
||||||
|
listInfo: ListInfo<StreamInfoItem>,
|
||||||
|
) : this(
|
||||||
|
uid = subscription.uid,
|
||||||
|
notificationMode = subscription.notificationMode,
|
||||||
|
name = subscription.name,
|
||||||
|
avatarUrl = subscription.avatarUrl,
|
||||||
|
listInfo = listInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integer id, can be used as notification id, etc.
|
||||||
|
*/
|
||||||
|
val pseudoId: Int
|
||||||
|
get() = listInfo.url.hashCode()
|
||||||
|
|
||||||
|
lateinit var newStreams: List<StreamInfoItem>
|
||||||
|
}
|
|
@ -84,7 +84,7 @@ public abstract class HistoryEntryAdapter<E, VH extends RecyclerView.ViewHolder>
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onViewRecycled(final VH holder) {
|
public void onViewRecycled(@NonNull final VH holder) {
|
||||||
super.onViewRecycled(holder);
|
super.onViewRecycled(holder);
|
||||||
holder.itemView.setOnClickListener(null);
|
holder.itemView.setOnClickListener(null);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
package org.schabi.newpipe.local.history;
|
package org.schabi.newpipe.local.history;
|
||||||
|
|
||||||
import android.app.Activity;
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Parcelable;
|
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.ErrorInfo;
|
||||||
import org.schabi.newpipe.error.UserAction;
|
import org.schabi.newpipe.error.UserAction;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
|
||||||
import org.schabi.newpipe.info_list.InfoItemDialog;
|
|
||||||
import org.schabi.newpipe.local.BaseLocalListFragment;
|
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.PlayQueue;
|
||||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
||||||
import org.schabi.newpipe.settings.HistorySettingsFragment;
|
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.NavigationHelper;
|
||||||
import org.schabi.newpipe.util.OnClickGesture;
|
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.ArrayList;
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -154,7 +149,7 @@ public class StatisticsPlaylistFragment
|
||||||
@Override
|
@Override
|
||||||
public void held(final LocalItem selectedItem) {
|
public void held(final LocalItem selectedItem) {
|
||||||
if (selectedItem instanceof StreamStatisticsEntry) {
|
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));
|
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 Context context = getContext();
|
||||||
final Activity activity = getActivity();
|
|
||||||
if (context == null || context.getResources() == null || activity == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final StreamInfoItem infoItem = item.toStreamInfoItem();
|
final StreamInfoItem infoItem = item.toStreamInfoItem();
|
||||||
|
|
||||||
final ArrayList<StreamDialogEntry> entries = new ArrayList<>();
|
try {
|
||||||
|
final InfoItemDialog.Builder dialogBuilder =
|
||||||
|
new InfoItemDialog.Builder(getActivity(), context, this, infoItem);
|
||||||
|
|
||||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
// set entries in the middle; the others are added automatically
|
||||||
entries.add(StreamDialogEntry.enqueue);
|
dialogBuilder
|
||||||
|
.addEntry(StreamDialogDefaultEntry.DELETE)
|
||||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
.setAction(
|
||||||
entries.add(StreamDialogEntry.enqueue_next);
|
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) {
|
private void deleteEntry(final int index) {
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package org.schabi.newpipe.local.playlist;
|
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.Context;
|
||||||
import android.content.DialogInterface;
|
import android.content.DialogInterface;
|
||||||
import android.os.Bundle;
|
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.ErrorInfo;
|
||||||
import org.schabi.newpipe.error.UserAction;
|
import org.schabi.newpipe.error.UserAction;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
|
||||||
import org.schabi.newpipe.info_list.InfoItemDialog;
|
|
||||||
import org.schabi.newpipe.local.BaseLocalListFragment;
|
import org.schabi.newpipe.local.BaseLocalListFragment;
|
||||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||||
import org.schabi.newpipe.player.MainPlayer.PlayerType;
|
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.PlayQueue;
|
||||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
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.Localization;
|
||||||
import org.schabi.newpipe.util.NavigationHelper;
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
import org.schabi.newpipe.util.OnClickGesture;
|
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.ArrayList;
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -68,9 +66,6 @@ import io.reactivex.rxjava3.disposables.Disposable;
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||||
import io.reactivex.rxjava3.subjects.PublishSubject;
|
import io.reactivex.rxjava3.subjects.PublishSubject;
|
||||||
|
|
||||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
|
||||||
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
|
|
||||||
|
|
||||||
public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistStreamEntry>, Void> {
|
public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistStreamEntry>, Void> {
|
||||||
// Save the list 10 seconds after the last change occurred
|
// Save the list 10 seconds after the last change occurred
|
||||||
private static final long SAVE_DEBOUNCE_MILLIS = 10000;
|
private static final long SAVE_DEBOUNCE_MILLIS = 10000;
|
||||||
|
@ -182,7 +177,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||||
@Override
|
@Override
|
||||||
public void held(final LocalItem selectedItem) {
|
public void held(final LocalItem selectedItem) {
|
||||||
if (selectedItem instanceof PlaylistStreamEntry) {
|
if (selectedItem instanceof PlaylistStreamEntry) {
|
||||||
showStreamItemDialog((PlaylistStreamEntry) selectedItem);
|
showInfoItemDialog((PlaylistStreamEntry) selectedItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -355,7 +350,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||||
new AlertDialog.Builder(requireContext())
|
new AlertDialog.Builder(requireContext())
|
||||||
.setMessage(R.string.remove_watched_popup_warning)
|
.setMessage(R.string.remove_watched_popup_warning)
|
||||||
.setTitle(R.string.remove_watched_popup_title)
|
.setTitle(R.string.remove_watched_popup_title)
|
||||||
.setPositiveButton(R.string.yes,
|
.setPositiveButton(R.string.ok,
|
||||||
(DialogInterface d, int id) -> removeWatchedStreams(false))
|
(DialogInterface d, int id) -> removeWatchedStreams(false))
|
||||||
.setNeutralButton(
|
.setNeutralButton(
|
||||||
R.string.remove_watched_popup_yes_and_partially_watched_videos,
|
R.string.remove_watched_popup_yes_and_partially_watched_videos,
|
||||||
|
@ -743,70 +738,39 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||||
return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0));
|
return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void showStreamItemDialog(final PlaylistStreamEntry item) {
|
protected void showInfoItemDialog(final PlaylistStreamEntry item) {
|
||||||
final Context context = getContext();
|
|
||||||
final Activity activity = getActivity();
|
|
||||||
if (context == null || context.getResources() == null || activity == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final StreamInfoItem infoItem = item.toStreamInfoItem();
|
final StreamInfoItem infoItem = item.toStreamInfoItem();
|
||||||
|
|
||||||
final ArrayList<StreamDialogEntry> entries = new ArrayList<>();
|
try {
|
||||||
|
final Context context = getContext();
|
||||||
|
final InfoItemDialog.Builder dialogBuilder =
|
||||||
|
new InfoItemDialog.Builder(getActivity(), context, this, infoItem);
|
||||||
|
|
||||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
// add entries in the middle
|
||||||
entries.add(StreamDialogEntry.enqueue);
|
dialogBuilder.addAllEntries(
|
||||||
|
StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL,
|
||||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
StreamDialogDefaultEntry.DELETE
|
||||||
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
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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) {
|
private void setInitialData(final long pid, final String title) {
|
||||||
|
|
|
@ -61,7 +61,7 @@ public class ImportConfirmationDialog extends DialogFragment {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onSaveInstanceState(final Bundle outState) {
|
public void onSaveInstanceState(@NonNull final Bundle outState) {
|
||||||
super.onSaveInstanceState(outState);
|
super.onSaveInstanceState(outState);
|
||||||
Icepick.saveInstanceState(this, outState);
|
Icepick.saveInstanceState(this, outState);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,8 @@ import io.reactivex.rxjava3.core.Flowable
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
import org.schabi.newpipe.NewPipeDatabase
|
import org.schabi.newpipe.NewPipeDatabase
|
||||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
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.SubscriptionDAO
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||||
import org.schabi.newpipe.extractor.ListInfo
|
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.feed.FeedInfo
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||||
|
import org.schabi.newpipe.util.ExtractorHelper
|
||||||
|
|
||||||
class SubscriptionManager(context: Context) {
|
class SubscriptionManager(context: Context) {
|
||||||
private val database = NewPipeDatabase.getInstance(context)
|
private val database = NewPipeDatabase.getInstance(context)
|
||||||
|
@ -66,13 +69,33 @@ class SubscriptionManager(context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable {
|
||||||
|
return subscriptionTable().getSubscription(serviceId, url)
|
||||||
|
.flatMapCompletable { entity: SubscriptionEntity ->
|
||||||
|
Completable.fromAction {
|
||||||
|
entity.notificationMode = mode
|
||||||
|
subscriptionTable().update(entity)
|
||||||
|
}.apply {
|
||||||
|
if (mode != NotificationMode.DISABLED) {
|
||||||
|
// notifications have just been enabled, mark all streams as "old"
|
||||||
|
andThen(rememberAllStreams(entity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun updateFromInfo(subscriptionId: Long, info: ListInfo<StreamInfoItem>) {
|
fun updateFromInfo(subscriptionId: Long, info: ListInfo<StreamInfoItem>) {
|
||||||
val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId)
|
val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId)
|
||||||
|
|
||||||
if (info is FeedInfo) {
|
if (info is FeedInfo) {
|
||||||
subscriptionEntity.name = info.name
|
subscriptionEntity.name = info.name
|
||||||
} else if (info is ChannelInfo) {
|
} 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)
|
subscriptionTable.update(subscriptionEntity)
|
||||||
|
@ -94,4 +117,19 @@ class SubscriptionManager(context: Context) {
|
||||||
fun deleteSubscription(subscriptionEntity: SubscriptionEntity) {
|
fun deleteSubscription(subscriptionEntity: SubscriptionEntity) {
|
||||||
subscriptionTable.delete(subscriptionEntity)
|
subscriptionTable.delete(subscriptionEntity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the list of videos for the provided channel and saves them in the database, so that
|
||||||
|
* they will be considered as "old"/"already seen" streams and the user will never be notified
|
||||||
|
* about any one of them.
|
||||||
|
*/
|
||||||
|
private fun rememberAllStreams(subscription: SubscriptionEntity): Completable {
|
||||||
|
return ExtractorHelper.getChannelInfo(subscription.serviceId, subscription.url, false)
|
||||||
|
.map { channel -> channel.relatedItems.map { stream -> StreamEntity(stream) } }
|
||||||
|
.flatMapCompletable { entities ->
|
||||||
|
Completable.fromAction {
|
||||||
|
database.streamDAO().upsertAll(entities)
|
||||||
|
}
|
||||||
|
}.onErrorComplete()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,6 @@ import androidx.lifecycle.ViewModelProvider
|
||||||
import io.reactivex.rxjava3.core.Completable
|
import io.reactivex.rxjava3.core.Completable
|
||||||
import io.reactivex.rxjava3.core.Flowable
|
import io.reactivex.rxjava3.core.Flowable
|
||||||
import io.reactivex.rxjava3.disposables.Disposable
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
import io.reactivex.rxjava3.functions.BiFunction
|
|
||||||
import io.reactivex.rxjava3.processors.BehaviorProcessor
|
import io.reactivex.rxjava3.processors.BehaviorProcessor
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
|
@ -33,9 +32,8 @@ class FeedGroupDialogViewModel(
|
||||||
private var subscriptionsFlowable = Flowable
|
private var subscriptionsFlowable = Flowable
|
||||||
.combineLatest(
|
.combineLatest(
|
||||||
filterSubscriptions.startWithItem(initialQuery),
|
filterSubscriptions.startWithItem(initialQuery),
|
||||||
toggleShowOnlyUngrouped.startWithItem(initialShowOnlyUngrouped),
|
toggleShowOnlyUngrouped.startWithItem(initialShowOnlyUngrouped)
|
||||||
BiFunction { t1: String, t2: Boolean -> Filter(t1, t2) }
|
) { t1: String, t2: Boolean -> Filter(t1, t2) }
|
||||||
)
|
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.switchMap { (query, showOnlyUngrouped) ->
|
.switchMap { (query, showOnlyUngrouped) ->
|
||||||
subscriptionManager.getSubscriptions(groupId, query, showOnlyUngrouped)
|
subscriptionManager.getSubscriptions(groupId, query, showOnlyUngrouped)
|
||||||
|
@ -56,9 +54,8 @@ class FeedGroupDialogViewModel(
|
||||||
|
|
||||||
private var subscriptionsDisposable = Flowable
|
private var subscriptionsDisposable = Flowable
|
||||||
.combineLatest(
|
.combineLatest(
|
||||||
subscriptionsFlowable, feedDatabaseManager.subscriptionIdsForGroup(groupId),
|
subscriptionsFlowable, feedDatabaseManager.subscriptionIdsForGroup(groupId)
|
||||||
BiFunction { t1: List<PickerSubscriptionItem>, t2: List<Long> -> t1 to t2.toSet() }
|
) { t1: List<PickerSubscriptionItem>, t2: List<Long> -> t1 to t2.toSet() }
|
||||||
)
|
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.subscribe(mutableSubscriptionsLiveData::postValue)
|
.subscribe(mutableSubscriptionsLiveData::postValue)
|
||||||
|
|
||||||
|
|
|
@ -57,15 +57,12 @@ class FeedGroupReorderDialog : DialogFragment() {
|
||||||
|
|
||||||
viewModel = ViewModelProvider(this).get(FeedGroupReorderDialogViewModel::class.java)
|
viewModel = ViewModelProvider(this).get(FeedGroupReorderDialogViewModel::class.java)
|
||||||
viewModel.groupsLiveData.observe(viewLifecycleOwner, Observer(::handleGroups))
|
viewModel.groupsLiveData.observe(viewLifecycleOwner, Observer(::handleGroups))
|
||||||
viewModel.dialogEventLiveData.observe(
|
viewModel.dialogEventLiveData.observe(viewLifecycleOwner) {
|
||||||
viewLifecycleOwner,
|
when (it) {
|
||||||
Observer {
|
ProcessingEvent -> disableInput()
|
||||||
when (it) {
|
SuccessEvent -> dismiss()
|
||||||
ProcessingEvent -> disableInput()
|
|
||||||
SuccessEvent -> dismiss()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
binding.feedGroupsList.layoutManager = LinearLayoutManager(requireContext())
|
binding.feedGroupsList.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.feedGroupsList.adapter = groupAdapter
|
binding.feedGroupsList.adapter = groupAdapter
|
||||||
|
|
|
@ -25,7 +25,6 @@ import com.grack.nanojson.JsonAppendableWriter;
|
||||||
import com.grack.nanojson.JsonArray;
|
import com.grack.nanojson.JsonArray;
|
||||||
import com.grack.nanojson.JsonObject;
|
import com.grack.nanojson.JsonObject;
|
||||||
import com.grack.nanojson.JsonParser;
|
import com.grack.nanojson.JsonParser;
|
||||||
import com.grack.nanojson.JsonSink;
|
|
||||||
import com.grack.nanojson.JsonWriter;
|
import com.grack.nanojson.JsonWriter;
|
||||||
|
|
||||||
import org.schabi.newpipe.BuildConfig;
|
import org.schabi.newpipe.BuildConfig;
|
||||||
|
@ -125,10 +124,11 @@ public final class ImportExportJsonHelper {
|
||||||
/**
|
/**
|
||||||
* @see #writeTo(List, OutputStream, ImportExportEventListener)
|
* @see #writeTo(List, OutputStream, ImportExportEventListener)
|
||||||
* @param items the list of subscriptions items
|
* @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
|
* @param eventListener listener for the events generated
|
||||||
*/
|
*/
|
||||||
public static void writeTo(final List<SubscriptionItem> items, final JsonSink writer,
|
public static void writeTo(final List<SubscriptionItem> items,
|
||||||
|
final JsonAppendableWriter writer,
|
||||||
@Nullable final ImportExportEventListener eventListener) {
|
@Nullable final ImportExportEventListener eventListener) {
|
||||||
if (eventListener != null) {
|
if (eventListener != null) {
|
||||||
eventListener.onSizeReceived(items.size());
|
eventListener.onSizeReceived(items.size());
|
||||||
|
|
|
@ -11,7 +11,6 @@ import androidx.preference.PreferenceManager;
|
||||||
import com.google.android.exoplayer2.C;
|
import com.google.android.exoplayer2.C;
|
||||||
import com.google.android.exoplayer2.MediaItem;
|
import com.google.android.exoplayer2.MediaItem;
|
||||||
import com.google.android.exoplayer2.PlaybackParameters;
|
import com.google.android.exoplayer2.PlaybackParameters;
|
||||||
import com.google.android.exoplayer2.Player.EventListener;
|
|
||||||
import com.google.android.exoplayer2.SeekParameters;
|
import com.google.android.exoplayer2.SeekParameters;
|
||||||
import com.google.android.exoplayer2.SimpleExoPlayer;
|
import com.google.android.exoplayer2.SimpleExoPlayer;
|
||||||
import com.google.android.exoplayer2.source.MediaSource;
|
import com.google.android.exoplayer2.source.MediaSource;
|
||||||
|
@ -36,7 +35,7 @@ import static org.schabi.newpipe.player.Player.STATE_PAUSED;
|
||||||
import static org.schabi.newpipe.player.Player.STATE_PAUSED_SEEK;
|
import static org.schabi.newpipe.player.Player.STATE_PAUSED_SEEK;
|
||||||
import static org.schabi.newpipe.player.Player.STATE_PLAYING;
|
import static org.schabi.newpipe.player.Player.STATE_PLAYING;
|
||||||
|
|
||||||
public class LocalPlayer implements EventListener {
|
public class LocalPlayer implements com.google.android.exoplayer2.Player.Listener {
|
||||||
private static final String TAG = "LocalPlayer";
|
private static final String TAG = "LocalPlayer";
|
||||||
private static final int PROGRESS_LOOP_INTERVAL_MILLIS = 500;
|
private static final int PROGRESS_LOOP_INTERVAL_MILLIS = 500;
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
package org.schabi.newpipe.player;
|
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.ComponentName;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
@ -25,11 +29,9 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||||
import com.google.android.exoplayer2.PlaybackParameters;
|
import com.google.android.exoplayer2.PlaybackParameters;
|
||||||
|
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
|
||||||
import org.schabi.newpipe.databinding.ActivityPlayerQueueControlBinding;
|
import org.schabi.newpipe.databinding.ActivityPlayerQueueControlBinding;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||||
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
|
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.event.PlayerEventListener;
|
||||||
import org.schabi.newpipe.player.helper.PlaybackParameterDialog;
|
import org.schabi.newpipe.player.helper.PlaybackParameterDialog;
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||||
|
@ -44,13 +46,6 @@ import org.schabi.newpipe.util.PermissionHelper;
|
||||||
import org.schabi.newpipe.util.ServiceHelper;
|
import org.schabi.newpipe.util.ServiceHelper;
|
||||||
import org.schabi.newpipe.util.ThemeHelper;
|
import org.schabi.newpipe.util.ThemeHelper;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu;
|
|
||||||
import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed;
|
|
||||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
|
||||||
|
|
||||||
import static org.schabi.newpipe.util.SponsorBlockUtils.markSegments;
|
import static org.schabi.newpipe.util.SponsorBlockUtils.markSegments;
|
||||||
|
|
||||||
public final class PlayQueueActivity extends AppCompatActivity
|
public final class PlayQueueActivity extends AppCompatActivity
|
||||||
|
@ -133,7 +128,7 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||||
NavigationHelper.openSettings(this);
|
NavigationHelper.openSettings(this);
|
||||||
return true;
|
return true;
|
||||||
case R.id.action_append_playlist:
|
case R.id.action_append_playlist:
|
||||||
appendAllToPlaylist();
|
player.onAddToPlaylistClicked(getSupportFragmentManager());
|
||||||
return true;
|
return true;
|
||||||
case R.id.action_playback_speed:
|
case R.id.action_playback_speed:
|
||||||
openPlaybackParameterDialog();
|
openPlaybackParameterDialog();
|
||||||
|
@ -453,24 +448,6 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||||
seeking = false;
|
seeking = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
// Playlist append
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
private void appendAllToPlaylist() {
|
|
||||||
if (player != null && player.getPlayQueue() != null) {
|
|
||||||
openPlaylistAppendDialog(player.getPlayQueue().getStreams());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void openPlaylistAppendDialog(final List<PlayQueueItem> playQueueItems) {
|
|
||||||
PlaylistDialog.createCorrespondingDialog(
|
|
||||||
getApplicationContext(),
|
|
||||||
playQueueItems.stream().map(StreamEntity::new).collect(Collectors.toList()),
|
|
||||||
dialog -> dialog.show(getSupportFragmentManager(), TAG)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
// Binding Service Listener
|
// Binding Service Listener
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
@ -634,7 +611,6 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||||
|
|
||||||
//2) Icon change accordingly to current App Theme
|
//2) Icon change accordingly to current App Theme
|
||||||
// using rootView.getContext() because getApplicationContext() didn't work
|
// 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);
|
item.setIcon(player.isMuted() ? R.drawable.ic_volume_off : R.drawable.ic_volume_up);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,6 @@
|
||||||
package org.schabi.newpipe.player.event;
|
package org.schabi.newpipe.player.event;
|
||||||
|
|
||||||
import com.google.android.exoplayer2.ExoPlaybackException;
|
import com.google.android.exoplayer2.PlaybackException;
|
||||||
|
|
||||||
public interface PlayerServiceEventListener extends PlayerEventListener {
|
public interface PlayerServiceEventListener extends PlayerEventListener {
|
||||||
void onFullscreenStateChanged(boolean fullscreen);
|
void onFullscreenStateChanged(boolean fullscreen);
|
||||||
|
@ -9,7 +9,7 @@ public interface PlayerServiceEventListener extends PlayerEventListener {
|
||||||
|
|
||||||
void onMoreOptionsLongClicked();
|
void onMoreOptionsLongClicked();
|
||||||
|
|
||||||
void onPlayerError(ExoPlaybackException error);
|
void onPlayerError(PlaybackException error, boolean isCatchableException);
|
||||||
|
|
||||||
void hideSystemUiIfNeeded();
|
void hideSystemUiIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ import androidx.core.content.ContextCompat;
|
||||||
import androidx.media.AudioFocusRequestCompat;
|
import androidx.media.AudioFocusRequestCompat;
|
||||||
import androidx.media.AudioManagerCompat;
|
import androidx.media.AudioManagerCompat;
|
||||||
|
|
||||||
import com.google.android.exoplayer2.SimpleExoPlayer;
|
import com.google.android.exoplayer2.ExoPlayer;
|
||||||
import com.google.android.exoplayer2.analytics.AnalyticsListener;
|
import com.google.android.exoplayer2.analytics.AnalyticsListener;
|
||||||
|
|
||||||
public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, 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 FOCUS_GAIN_TYPE = AudioManagerCompat.AUDIOFOCUS_GAIN;
|
||||||
private static final int STREAM_TYPE = AudioManager.STREAM_MUSIC;
|
private static final int STREAM_TYPE = AudioManager.STREAM_MUSIC;
|
||||||
|
|
||||||
private final SimpleExoPlayer player;
|
private final ExoPlayer player;
|
||||||
private final Context context;
|
private final Context context;
|
||||||
private final AudioManager audioManager;
|
private final AudioManager audioManager;
|
||||||
|
|
||||||
private final AudioFocusRequestCompat request;
|
private final AudioFocusRequestCompat request;
|
||||||
|
|
||||||
public AudioReactor(@NonNull final Context context,
|
public AudioReactor(@NonNull final Context context,
|
||||||
@NonNull final SimpleExoPlayer player) {
|
@NonNull final ExoPlayer player) {
|
||||||
this.player = player;
|
this.player = player;
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.audioManager = ContextCompat.getSystemService(context, AudioManager.class);
|
this.audioManager = ContextCompat.getSystemService(context, AudioManager.class);
|
||||||
|
@ -149,7 +149,8 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, An
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAudioSessionIdChanged(final EventTime eventTime, final int audioSessionId) {
|
public void onAudioSessionIdChanged(@NonNull final EventTime eventTime,
|
||||||
|
final int audioSessionId) {
|
||||||
notifyAudioSessionUpdate(true, audioSessionId);
|
notifyAudioSessionUpdate(true, audioSessionId);
|
||||||
}
|
}
|
||||||
private void notifyAudioSessionUpdate(final boolean active, final int audioSessionId) {
|
private void notifyAudioSessionUpdate(final boolean active, final int audioSessionId) {
|
||||||
|
|
|
@ -3,12 +3,10 @@ package org.schabi.newpipe.player.helper;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider;
|
||||||
|
|
||||||
import com.google.android.exoplayer2.database.ExoDatabaseProvider;
|
|
||||||
import com.google.android.exoplayer2.upstream.DataSource;
|
import com.google.android.exoplayer2.upstream.DataSource;
|
||||||
import com.google.android.exoplayer2.upstream.DefaultDataSource;
|
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.FileDataSource;
|
||||||
import com.google.android.exoplayer2.upstream.TransferListener;
|
import com.google.android.exoplayer2.upstream.TransferListener;
|
||||||
import com.google.android.exoplayer2.upstream.cache.CacheDataSink;
|
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 java.io.File;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
/* package-private */ class CacheFactory implements DataSource.Factory {
|
/* package-private */ class CacheFactory implements DataSource.Factory {
|
||||||
private static final String TAG = "CacheFactory";
|
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
|
private static final int CACHE_FLAGS = CacheDataSource.FLAG_BLOCK_ON_CACHE
|
||||||
| CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR;
|
| CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR;
|
||||||
|
|
||||||
private final DefaultDataSourceFactory dataSourceFactory;
|
private final DataSource.Factory dataSourceFactory;
|
||||||
private final File cacheDir;
|
private final File cacheDir;
|
||||||
private final long maxFileSize;
|
private final long maxFileSize;
|
||||||
|
|
||||||
|
@ -49,7 +49,9 @@ import java.io.File;
|
||||||
final long maxFileSize) {
|
final long maxFileSize) {
|
||||||
this.maxFileSize = 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);
|
cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME);
|
||||||
if (!cacheDir.exists()) {
|
if (!cacheDir.exists()) {
|
||||||
//noinspection ResultOfMethodCallIgnored
|
//noinspection ResultOfMethodCallIgnored
|
||||||
|
@ -59,15 +61,16 @@ import java.io.File;
|
||||||
if (cache == null) {
|
if (cache == null) {
|
||||||
final LeastRecentlyUsedCacheEvictor evictor
|
final LeastRecentlyUsedCacheEvictor evictor
|
||||||
= new LeastRecentlyUsedCacheEvictor(maxCacheSize);
|
= new LeastRecentlyUsedCacheEvictor(maxCacheSize);
|
||||||
cache = new SimpleCache(cacheDir, evictor, new ExoDatabaseProvider(context));
|
cache = new SimpleCache(cacheDir, evictor, new StandaloneDatabaseProvider(context));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
@Override
|
@Override
|
||||||
public DataSource createDataSource() {
|
public DataSource createDataSource() {
|
||||||
Log.d(TAG, "initExoPlayerCache: cacheDir = " + cacheDir.getAbsolutePath());
|
Log.d(TAG, "initExoPlayerCache: cacheDir = " + cacheDir.getAbsolutePath());
|
||||||
|
|
||||||
final DefaultDataSource dataSource = dataSourceFactory.createDataSource();
|
final DataSource dataSource = dataSourceFactory.createDataSource();
|
||||||
final FileDataSource fileSource = new FileDataSource();
|
final FileDataSource fileSource = new FileDataSource();
|
||||||
final CacheDataSink dataSink = new CacheDataSink(cache, maxFileSize);
|
final CacheDataSink dataSink = new CacheDataSink(cache, maxFileSize);
|
||||||
|
|
||||||
|
@ -86,8 +89,8 @@ import java.io.File;
|
||||||
|
|
||||||
Log.d(TAG, "tryDeleteCacheFiles: " + filePath + " deleted = " + deleteSuccessful);
|
Log.d(TAG, "tryDeleteCacheFiles: " + filePath + " deleted = " + deleteSuccessful);
|
||||||
}
|
}
|
||||||
} catch (final Exception ignored) {
|
} catch (final Exception e) {
|
||||||
Log.e(TAG, "Failed to delete file.", ignored);
|
Log.e(TAG, "Failed to delete file.", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,6 @@ import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||||
import org.schabi.newpipe.MainActivity;
|
import org.schabi.newpipe.MainActivity;
|
||||||
import org.schabi.newpipe.player.mediasession.MediaSessionCallback;
|
import org.schabi.newpipe.player.mediasession.MediaSessionCallback;
|
||||||
import org.schabi.newpipe.player.mediasession.PlayQueueNavigator;
|
import org.schabi.newpipe.player.mediasession.PlayQueueNavigator;
|
||||||
import org.schabi.newpipe.player.mediasession.PlayQueuePlaybackController;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@ -55,7 +54,6 @@ public class MediaSessionManager {
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
sessionConnector = new MediaSessionConnector(mediaSession);
|
sessionConnector = new MediaSessionConnector(mediaSession);
|
||||||
sessionConnector.setControlDispatcher(new PlayQueuePlaybackController(callback));
|
|
||||||
sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, callback));
|
sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, callback));
|
||||||
sessionConnector.setPlayer(player);
|
sessionConnector.setPlayer(player);
|
||||||
}
|
}
|
||||||
|
@ -135,9 +133,7 @@ public class MediaSessionManager {
|
||||||
lastTitleHashCode = title.hashCode();
|
lastTitleHashCode = title.hashCode();
|
||||||
lastArtistHashCode = artist.hashCode();
|
lastArtistHashCode = artist.hashCode();
|
||||||
lastDuration = duration;
|
lastDuration = duration;
|
||||||
if (optAlbumArt.isPresent()) {
|
optAlbumArt.ifPresent(bitmap -> lastAlbumArtHashCode = bitmap.hashCode());
|
||||||
lastAlbumArtHashCode = optAlbumArt.get().hashCode();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean checkIfMetadataShouldBeSet(
|
private boolean checkIfMetadataShouldBeSet(
|
||||||
|
|
|
@ -9,6 +9,7 @@ import android.os.Bundle;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.widget.CheckBox;
|
import android.widget.CheckBox;
|
||||||
|
import android.widget.RelativeLayout;
|
||||||
import android.widget.SeekBar;
|
import android.widget.SeekBar;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
@ -19,6 +20,7 @@ import androidx.fragment.app.DialogFragment;
|
||||||
import androidx.preference.PreferenceManager;
|
import androidx.preference.PreferenceManager;
|
||||||
|
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
|
||||||
import org.schabi.newpipe.util.SliderStrategy;
|
import org.schabi.newpipe.util.SliderStrategy;
|
||||||
|
|
||||||
public class PlaybackParameterDialog extends DialogFragment {
|
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_TEMPO = 1.00f;
|
||||||
private static final double DEFAULT_PITCH = 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 double DEFAULT_STEP = STEP_TWENTY_FIVE_PERCENT_VALUE;
|
||||||
private static final boolean DEFAULT_SKIP_SILENCE = false;
|
private static final boolean DEFAULT_SKIP_SILENCE = false;
|
||||||
|
|
||||||
|
@ -64,10 +67,11 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||||
|
|
||||||
private double initialTempo = DEFAULT_TEMPO;
|
private double initialTempo = DEFAULT_TEMPO;
|
||||||
private double initialPitch = DEFAULT_PITCH;
|
private double initialPitch = DEFAULT_PITCH;
|
||||||
|
private int initialSemitones = DEFAULT_SEMITONES;
|
||||||
private boolean initialSkipSilence = DEFAULT_SKIP_SILENCE;
|
private boolean initialSkipSilence = DEFAULT_SKIP_SILENCE;
|
||||||
private double tempo = DEFAULT_TEMPO;
|
private double tempo = DEFAULT_TEMPO;
|
||||||
private double pitch = DEFAULT_PITCH;
|
private double pitch = DEFAULT_PITCH;
|
||||||
private double stepSize = DEFAULT_STEP;
|
private int semitones = DEFAULT_SEMITONES;
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
private SeekBar tempoSlider;
|
private SeekBar tempoSlider;
|
||||||
|
@ -86,9 +90,19 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||||
@Nullable
|
@Nullable
|
||||||
private TextView pitchStepUpText;
|
private TextView pitchStepUpText;
|
||||||
@Nullable
|
@Nullable
|
||||||
|
private SeekBar semitoneSlider;
|
||||||
|
@Nullable
|
||||||
|
private TextView semitoneCurrentText;
|
||||||
|
@Nullable
|
||||||
|
private TextView semitoneStepDownText;
|
||||||
|
@Nullable
|
||||||
|
private TextView semitoneStepUpText;
|
||||||
|
@Nullable
|
||||||
private CheckBox unhookingCheckbox;
|
private CheckBox unhookingCheckbox;
|
||||||
@Nullable
|
@Nullable
|
||||||
private CheckBox skipSilenceCheckbox;
|
private CheckBox skipSilenceCheckbox;
|
||||||
|
@Nullable
|
||||||
|
private CheckBox adjustBySemitonesCheckbox;
|
||||||
|
|
||||||
public static PlaybackParameterDialog newInstance(final double playbackTempo,
|
public static PlaybackParameterDialog newInstance(final double playbackTempo,
|
||||||
final double playbackPitch,
|
final double playbackPitch,
|
||||||
|
@ -101,6 +115,7 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||||
|
|
||||||
dialog.tempo = playbackTempo;
|
dialog.tempo = playbackTempo;
|
||||||
dialog.pitch = playbackPitch;
|
dialog.pitch = playbackPitch;
|
||||||
|
dialog.semitones = dialog.percentToSemitones(playbackPitch);
|
||||||
|
|
||||||
dialog.initialSkipSilence = playbackSkipSilence;
|
dialog.initialSkipSilence = playbackSkipSilence;
|
||||||
return dialog;
|
return dialog;
|
||||||
|
@ -111,7 +126,7 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAttach(final Context context) {
|
public void onAttach(@NonNull final Context context) {
|
||||||
super.onAttach(context);
|
super.onAttach(context);
|
||||||
if (context instanceof Callback) {
|
if (context instanceof Callback) {
|
||||||
callback = (Callback) context;
|
callback = (Callback) context;
|
||||||
|
@ -127,22 +142,22 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||||
if (savedInstanceState != null) {
|
if (savedInstanceState != null) {
|
||||||
initialTempo = savedInstanceState.getDouble(INITIAL_TEMPO_KEY, DEFAULT_TEMPO);
|
initialTempo = savedInstanceState.getDouble(INITIAL_TEMPO_KEY, DEFAULT_TEMPO);
|
||||||
initialPitch = savedInstanceState.getDouble(INITIAL_PITCH_KEY, DEFAULT_PITCH);
|
initialPitch = savedInstanceState.getDouble(INITIAL_PITCH_KEY, DEFAULT_PITCH);
|
||||||
|
initialSemitones = percentToSemitones(initialPitch);
|
||||||
|
|
||||||
tempo = savedInstanceState.getDouble(TEMPO_KEY, DEFAULT_TEMPO);
|
tempo = savedInstanceState.getDouble(TEMPO_KEY, DEFAULT_TEMPO);
|
||||||
pitch = savedInstanceState.getDouble(PITCH_KEY, DEFAULT_PITCH);
|
pitch = savedInstanceState.getDouble(PITCH_KEY, DEFAULT_PITCH);
|
||||||
stepSize = savedInstanceState.getDouble(STEP_SIZE_KEY, DEFAULT_STEP);
|
semitones = percentToSemitones(pitch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onSaveInstanceState(final Bundle outState) {
|
public void onSaveInstanceState(@NonNull final Bundle outState) {
|
||||||
super.onSaveInstanceState(outState);
|
super.onSaveInstanceState(outState);
|
||||||
outState.putDouble(INITIAL_TEMPO_KEY, initialTempo);
|
outState.putDouble(INITIAL_TEMPO_KEY, initialTempo);
|
||||||
outState.putDouble(INITIAL_PITCH_KEY, initialPitch);
|
outState.putDouble(INITIAL_PITCH_KEY, initialPitch);
|
||||||
|
|
||||||
outState.putDouble(TEMPO_KEY, getCurrentTempo());
|
outState.putDouble(TEMPO_KEY, getCurrentTempo());
|
||||||
outState.putDouble(PITCH_KEY, getCurrentPitch());
|
outState.putDouble(PITCH_KEY, getCurrentPitch());
|
||||||
outState.putDouble(STEP_SIZE_KEY, getCurrentStepSize());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
@ -160,9 +175,11 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||||
.setView(view)
|
.setView(view)
|
||||||
.setCancelable(true)
|
.setCancelable(true)
|
||||||
.setNegativeButton(R.string.cancel, (dialogInterface, i) ->
|
.setNegativeButton(R.string.cancel, (dialogInterface, i) ->
|
||||||
setPlaybackParameters(initialTempo, initialPitch, initialSkipSilence))
|
setPlaybackParameters(initialTempo, initialPitch,
|
||||||
|
initialSemitones, initialSkipSilence))
|
||||||
.setNeutralButton(R.string.playback_reset, (dialogInterface, i) ->
|
.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) ->
|
.setPositiveButton(R.string.ok, (dialogInterface, i) ->
|
||||||
setCurrentPlaybackParameters());
|
setCurrentPlaybackParameters());
|
||||||
|
|
||||||
|
@ -176,14 +193,49 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||||
private void setupControlViews(@NonNull final View rootView) {
|
private void setupControlViews(@NonNull final View rootView) {
|
||||||
setupHookingControl(rootView);
|
setupHookingControl(rootView);
|
||||||
setupSkipSilenceControl(rootView);
|
setupSkipSilenceControl(rootView);
|
||||||
|
setupAdjustBySemitonesControl(rootView);
|
||||||
|
|
||||||
setupTempoControl(rootView);
|
setupTempoControl(rootView);
|
||||||
setupPitchControl(rootView);
|
setupPitchControl(rootView);
|
||||||
|
setupSemitoneControl(rootView);
|
||||||
|
|
||||||
|
togglePitchSliderType(rootView);
|
||||||
|
|
||||||
setStepSize(stepSize);
|
|
||||||
setupStepSizeSelector(rootView);
|
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) {
|
private void setupTempoControl(@NonNull final View rootView) {
|
||||||
tempoSlider = rootView.findViewById(R.id.tempoSeekbar);
|
tempoSlider = rootView.findViewById(R.id.tempoSeekbar);
|
||||||
final TextView tempoMinimumText = rootView.findViewById(R.id.tempoMinimumText);
|
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) {
|
private void setupHookingControl(@NonNull final View rootView) {
|
||||||
unhookingCheckbox = rootView.findViewById(R.id.unhookCheckbox);
|
unhookingCheckbox = rootView.findViewById(R.id.unhookCheckbox);
|
||||||
if (unhookingCheckbox != null) {
|
if (unhookingCheckbox != null) {
|
||||||
// restore whether pitch and tempo are unhooked or not
|
// restores whether pitch and tempo are unhooked or not
|
||||||
unhookingCheckbox.setChecked(PreferenceManager
|
unhookingCheckbox.setChecked(PreferenceManager
|
||||||
.getDefaultSharedPreferences(requireContext())
|
.getDefaultSharedPreferences(requireContext())
|
||||||
.getBoolean(getString(R.string.playback_unhook_key), true));
|
.getBoolean(getString(R.string.playback_unhook_key), true));
|
||||||
|
|
||||||
unhookingCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> {
|
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())
|
PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||||
.edit()
|
.edit()
|
||||||
.putBoolean(getString(R.string.playback_unhook_key), isChecked)
|
.putBoolean(getString(R.string.playback_unhook_key), isChecked)
|
||||||
.apply();
|
.apply();
|
||||||
|
|
||||||
if (!isChecked) {
|
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());
|
final double minimum = Math.min(getCurrentPitch(), getCurrentTempo());
|
||||||
setSliders(minimum);
|
setSliders(minimum);
|
||||||
setCurrentPlaybackParameters();
|
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) {
|
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 stepSizeOnePercentText = rootView.findViewById(R.id.stepSizeOnePercent);
|
||||||
final TextView stepSizeFivePercentText = rootView.findViewById(R.id.stepSizeFivePercent);
|
final TextView stepSizeFivePercentText = rootView.findViewById(R.id.stepSizeFivePercent);
|
||||||
final TextView stepSizeTenPercentText = rootView.findViewById(R.id.stepSizeTenPercent);
|
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) {
|
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) {
|
if (tempoStepUpText != null) {
|
||||||
tempoStepUpText.setText(getStepUpPercentString(stepSize));
|
tempoStepUpText.setText(getStepUpPercentString(stepSize));
|
||||||
|
@ -344,16 +476,30 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||||
setCurrentPlaybackParameters();
|
setCurrentPlaybackParameters();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (semitoneStepDownText != null) {
|
||||||
|
semitoneStepDownText.setOnClickListener(view -> {
|
||||||
|
onSemitoneSliderUpdated(getCurrentSemitones() - 1);
|
||||||
|
setCurrentPlaybackParameters();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (semitoneStepUpText != null) {
|
||||||
|
semitoneStepUpText.setOnClickListener(view -> {
|
||||||
|
onSemitoneSliderUpdated(getCurrentSemitones() + 1);
|
||||||
|
setCurrentPlaybackParameters();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
// Sliders
|
// Sliders
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
private SeekBar.OnSeekBarChangeListener getOnTempoChangedListener() {
|
private SimpleOnSeekBarChangeListener getOnTempoChangedListener() {
|
||||||
return new SeekBar.OnSeekBarChangeListener() {
|
return new SimpleOnSeekBarChangeListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onProgressChanged(final SeekBar seekBar, final int progress,
|
public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress,
|
||||||
final boolean fromUser) {
|
final boolean fromUser) {
|
||||||
final double currentTempo = strategy.valueOf(progress);
|
final double currentTempo = strategy.valueOf(progress);
|
||||||
if (fromUser) {
|
if (fromUser) {
|
||||||
|
@ -361,23 +507,13 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||||
setCurrentPlaybackParameters();
|
setCurrentPlaybackParameters();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStartTrackingTouch(final SeekBar seekBar) {
|
|
||||||
// Do Nothing.
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStopTrackingTouch(final SeekBar seekBar) {
|
|
||||||
// Do Nothing.
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private SeekBar.OnSeekBarChangeListener getOnPitchChangedListener() {
|
private SimpleOnSeekBarChangeListener getOnPitchChangedListener() {
|
||||||
return new SeekBar.OnSeekBarChangeListener() {
|
return new SimpleOnSeekBarChangeListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onProgressChanged(final SeekBar seekBar, final int progress,
|
public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress,
|
||||||
final boolean fromUser) {
|
final boolean fromUser) {
|
||||||
final double currentPitch = strategy.valueOf(progress);
|
final double currentPitch = strategy.valueOf(progress);
|
||||||
if (fromUser) { // this change is first in chain
|
if (fromUser) { // this change is first in chain
|
||||||
|
@ -385,23 +521,27 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||||
setCurrentPlaybackParameters();
|
setCurrentPlaybackParameters();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimpleOnSeekBarChangeListener getOnSemitoneChangedListener() {
|
||||||
|
return new SimpleOnSeekBarChangeListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onStartTrackingTouch(final SeekBar seekBar) {
|
public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress,
|
||||||
// Do Nothing.
|
final boolean fromUser) {
|
||||||
}
|
// semitone slider supplies values 0 to 24, subtraction by 12 is required
|
||||||
|
final int currentSemitones = progress - 12;
|
||||||
@Override
|
if (fromUser) { // this change is first in chain
|
||||||
public void onStopTrackingTouch(final SeekBar seekBar) {
|
onSemitoneSliderUpdated(currentSemitones);
|
||||||
// Do Nothing.
|
// line below also saves semitones as pitch percentages
|
||||||
|
onPitchSliderUpdated(semitonesToPercent(currentSemitones));
|
||||||
|
setCurrentPlaybackParameters();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onTempoSliderUpdated(final double newTempo) {
|
private void onTempoSliderUpdated(final double newTempo) {
|
||||||
if (unhookingCheckbox == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!unhookingCheckbox.isChecked()) {
|
if (!unhookingCheckbox.isChecked()) {
|
||||||
setSliders(newTempo);
|
setSliders(newTempo);
|
||||||
} else {
|
} else {
|
||||||
|
@ -410,9 +550,6 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onPitchSliderUpdated(final double newPitch) {
|
private void onPitchSliderUpdated(final double newPitch) {
|
||||||
if (unhookingCheckbox == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!unhookingCheckbox.isChecked()) {
|
if (!unhookingCheckbox.isChecked()) {
|
||||||
setSliders(newPitch);
|
setSliders(newPitch);
|
||||||
} else {
|
} else {
|
||||||
|
@ -420,6 +557,10 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void onSemitoneSliderUpdated(final int newSemitone) {
|
||||||
|
setSemitoneSlider(newSemitone);
|
||||||
|
}
|
||||||
|
|
||||||
private void setSliders(final double newValue) {
|
private void setSliders(final double newValue) {
|
||||||
setTempoSlider(newValue);
|
setTempoSlider(newValue);
|
||||||
setPitchSlider(newValue);
|
setPitchSlider(newValue);
|
||||||
|
@ -439,25 +580,49 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||||
pitchSlider.setProgress(strategy.progressOf(newPitch));
|
pitchSlider.setProgress(strategy.progressOf(newPitch));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void setSemitoneSlider(final int newSemitone) {
|
||||||
|
if (semitoneSlider == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
semitoneSlider.setProgress(newSemitone + 12);
|
||||||
|
}
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
// Helper
|
// Helper
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
private void setCurrentPlaybackParameters() {
|
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,
|
private void setPlaybackParameters(final double newTempo, final double newPitch,
|
||||||
final boolean skipSilence) {
|
final int newSemitones, final boolean skipSilence) {
|
||||||
if (callback != null && tempoCurrentText != null && pitchCurrentText != null) {
|
if (callback != null && tempoCurrentText != null
|
||||||
|
&& pitchCurrentText != null && semitoneCurrentText != null) {
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
Log.d(TAG, "Setting playback parameters to "
|
Log.d(TAG, "Setting playback parameters to "
|
||||||
+ "tempo=[" + newTempo + "], "
|
+ "tempo=[" + newTempo + "], "
|
||||||
+ "pitch=[" + newPitch + "]");
|
+ "pitch=[" + newPitch + "], "
|
||||||
|
+ "semitones=[" + newSemitones + "]");
|
||||||
}
|
}
|
||||||
|
|
||||||
tempoCurrentText.setText(PlayerHelper.formatSpeed(newTempo));
|
tempoCurrentText.setText(PlayerHelper.formatSpeed(newTempo));
|
||||||
pitchCurrentText.setText(PlayerHelper.formatPitch(newPitch));
|
pitchCurrentText.setText(PlayerHelper.formatPitch(newPitch));
|
||||||
|
semitoneCurrentText.setText(getSignedSemitonesString(newSemitones));
|
||||||
callback.onPlaybackParameterChanged((float) newTempo, (float) newPitch, skipSilence);
|
callback.onPlaybackParameterChanged((float) newTempo, (float) newPitch, skipSilence);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -470,14 +635,19 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||||
return pitchSlider == null ? pitch : strategy.valueOf(pitchSlider.getProgress());
|
return pitchSlider == null ? pitch : strategy.valueOf(pitchSlider.getProgress());
|
||||||
}
|
}
|
||||||
|
|
||||||
private double getCurrentStepSize() {
|
private int getCurrentSemitones() {
|
||||||
return stepSize;
|
// semitoneSlider is absolute, that's why - 12
|
||||||
|
return semitoneSlider == null ? semitones : semitoneSlider.getProgress() - 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean getCurrentSkipSilence() {
|
private boolean getCurrentSkipSilence() {
|
||||||
return skipSilenceCheckbox != null && skipSilenceCheckbox.isChecked();
|
return skipSilenceCheckbox != null && skipSilenceCheckbox.isChecked();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean getCurrentAdjustBySemitones() {
|
||||||
|
return adjustBySemitonesCheckbox != null && adjustBySemitonesCheckbox.isChecked();
|
||||||
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
private static String getStepUpPercentString(final double percent) {
|
private static String getStepUpPercentString(final double percent) {
|
||||||
return STEP_UP_SIGN + getPercentString(percent);
|
return STEP_UP_SIGN + getPercentString(percent);
|
||||||
|
@ -493,8 +663,21 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||||
return PlayerHelper.formatPitch(percent);
|
return PlayerHelper.formatPitch(percent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private static String getSignedSemitonesString(final int semitones) {
|
||||||
|
return semitones > 0 ? "+" + semitones : "" + semitones;
|
||||||
|
}
|
||||||
|
|
||||||
public interface Callback {
|
public interface Callback {
|
||||||
void onPlaybackParameterChanged(float playbackTempo, float playbackPitch,
|
void onPlaybackParameterChanged(float playbackTempo, float playbackPitch,
|
||||||
boolean playbackSkipSilence);
|
boolean playbackSkipSilence);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public double semitonesToPercent(final int inSemitones) {
|
||||||
|
return Math.pow(2, inSemitones / 12.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int percentToSemitones(final double inPercent) {
|
||||||
|
return (int) Math.round(12 * Math.log(inPercent) / Math.log(2));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,6 @@ package org.schabi.newpipe.player.helper;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
|
|
||||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
|
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
|
||||||
import com.google.android.exoplayer2.source.SingleSampleMediaSource;
|
import com.google.android.exoplayer2.source.SingleSampleMediaSource;
|
||||||
import com.google.android.exoplayer2.source.dash.DashMediaSource;
|
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.DefaultSsChunkSource;
|
||||||
import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource;
|
import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource;
|
||||||
import com.google.android.exoplayer2.upstream.DataSource;
|
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.DefaultLoadErrorHandlingPolicy;
|
||||||
import com.google.android.exoplayer2.upstream.TransferListener;
|
import com.google.android.exoplayer2.upstream.TransferListener;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
public class PlayerDataSource {
|
public class PlayerDataSource {
|
||||||
|
|
||||||
public static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000;
|
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 MANIFEST_MINIMUM_RETRY = 5;
|
||||||
private static final int EXTRACTOR_MINIMUM_RETRY = Integer.MAX_VALUE;
|
private static final int EXTRACTOR_MINIMUM_RETRY = Integer.MAX_VALUE;
|
||||||
|
|
||||||
|
private final int continueLoadingCheckIntervalBytes;
|
||||||
private final DataSource.Factory cacheDataSourceFactory;
|
private final DataSource.Factory cacheDataSourceFactory;
|
||||||
private final DataSource.Factory cachelessDataSourceFactory;
|
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) {
|
@NonNull final TransferListener transferListener) {
|
||||||
|
continueLoadingCheckIntervalBytes = PlayerHelper.getProgressiveLoadIntervalBytes(context);
|
||||||
cacheDataSourceFactory = new CacheFactory(context, userAgent, transferListener);
|
cacheDataSourceFactory = new CacheFactory(context, userAgent, transferListener);
|
||||||
cachelessDataSourceFactory
|
cachelessDataSourceFactory = new DefaultDataSource
|
||||||
= new DefaultDataSourceFactory(context, userAgent, transferListener);
|
.Factory(context, new DefaultHttpDataSource.Factory().setUserAgent(userAgent))
|
||||||
|
.setTransferListener(transferListener);
|
||||||
}
|
}
|
||||||
|
|
||||||
public SsMediaSource.Factory getLiveSsMediaSourceFactory() {
|
public SsMediaSource.Factory getLiveSsMediaSourceFactory() {
|
||||||
|
@ -91,6 +96,7 @@ public class PlayerDataSource {
|
||||||
|
|
||||||
public ProgressiveMediaSource.Factory getExtractorMediaSourceFactory() {
|
public ProgressiveMediaSource.Factory getExtractorMediaSourceFactory() {
|
||||||
return new ProgressiveMediaSource.Factory(cacheDataSourceFactory)
|
return new ProgressiveMediaSource.Factory(cacheDataSourceFactory)
|
||||||
|
.setContinueLoadingCheckIntervalBytes(continueLoadingCheckIntervalBytes)
|
||||||
.setLoadErrorHandlingPolicy(
|
.setLoadErrorHandlingPolicy(
|
||||||
new DefaultLoadErrorHandlingPolicy(EXTRACTOR_MINIMUM_RETRY));
|
new DefaultLoadErrorHandlingPolicy(EXTRACTOR_MINIMUM_RETRY));
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,7 @@ import androidx.preference.PreferenceManager;
|
||||||
import com.google.android.exoplayer2.PlaybackParameters;
|
import com.google.android.exoplayer2.PlaybackParameters;
|
||||||
import com.google.android.exoplayer2.Player.RepeatMode;
|
import com.google.android.exoplayer2.Player.RepeatMode;
|
||||||
import com.google.android.exoplayer2.SeekParameters;
|
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.AdaptiveTrackSelection;
|
||||||
import com.google.android.exoplayer2.trackselection.ExoTrackSelection;
|
import com.google.android.exoplayer2.trackselection.ExoTrackSelection;
|
||||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
|
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 SPEED_FORMATTER = new DecimalFormat("0.##x");
|
||||||
private static final NumberFormat PITCH_FORMATTER = new DecimalFormat("##%");
|
private static final NumberFormat PITCH_FORMATTER = new DecimalFormat("##%");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum opacity allowed for Android 12 and higher to allow touches on other apps when using
|
||||||
|
* NewPipe's popup player.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This value is hardcoded instead of being get dynamically with the method linked of the
|
||||||
|
* constant documentation below, because it is not static and popup player layout parameters
|
||||||
|
* are generated with static methods.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE
|
||||||
|
*/
|
||||||
|
private static final float MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER = 0.8f;
|
||||||
|
|
||||||
@Retention(SOURCE)
|
@Retention(SOURCE)
|
||||||
@IntDef({AUTOPLAY_TYPE_ALWAYS, AUTOPLAY_TYPE_WIFI,
|
@IntDef({AUTOPLAY_TYPE_ALWAYS, AUTOPLAY_TYPE_WIFI,
|
||||||
AUTOPLAY_TYPE_NEVER})
|
AUTOPLAY_TYPE_NEVER})
|
||||||
|
@ -143,6 +158,21 @@ public final class PlayerHelper {
|
||||||
? " (" + context.getString(R.string.caption_auto_generated) + ")" : "");
|
? " (" + 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
|
@NonNull
|
||||||
public static String resizeTypeOf(@NonNull final Context context,
|
public static String resizeTypeOf(@NonNull final Context context,
|
||||||
@ResizeMode final int resizeMode) {
|
@ResizeMode final int resizeMode) {
|
||||||
|
@ -391,6 +421,19 @@ public final class PlayerHelper {
|
||||||
context.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) == 0;
|
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
|
// Private helpers
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
@ -558,6 +601,12 @@ public final class PlayerHelper {
|
||||||
flags,
|
flags,
|
||||||
PixelFormat.TRANSLUCENT);
|
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.gravity = Gravity.LEFT | Gravity.TOP;
|
||||||
closeOverlayLayoutParams.softInputMode =
|
closeOverlayLayoutParams.softInputMode =
|
||||||
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
|
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
|
||||||
|
|
|
@ -10,7 +10,7 @@ import android.util.Log;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.core.content.ContextCompat;
|
import androidx.core.content.ContextCompat;
|
||||||
|
|
||||||
import com.google.android.exoplayer2.ExoPlaybackException;
|
import com.google.android.exoplayer2.PlaybackException;
|
||||||
import com.google.android.exoplayer2.PlaybackParameters;
|
import com.google.android.exoplayer2.PlaybackParameters;
|
||||||
|
|
||||||
import org.schabi.newpipe.App;
|
import org.schabi.newpipe.App;
|
||||||
|
@ -233,9 +233,10 @@ public final class PlayerHolder {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onPlayerError(final ExoPlaybackException error) {
|
public void onPlayerError(final PlaybackException error,
|
||||||
|
final boolean isCatchableException) {
|
||||||
if (listener != null) {
|
if (listener != null) {
|
||||||
listener.onPlayerError(error);
|
listener.onPlayerError(error, isCatchableException);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
package org.schabi.newpipe.player.listeners.view
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import androidx.appcompat.widget.PopupMenu
|
||||||
|
import org.schabi.newpipe.MainActivity
|
||||||
|
import org.schabi.newpipe.player.Player
|
||||||
|
import org.schabi.newpipe.player.helper.PlaybackParameterDialog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click listener for the playbackSpeed textview of the player
|
||||||
|
*/
|
||||||
|
class PlaybackSpeedClickListener(
|
||||||
|
private val player: Player,
|
||||||
|
private val playbackSpeedPopupMenu: PopupMenu
|
||||||
|
) : View.OnClickListener {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG: String = "PlaybSpeedClickListener"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(v: View) {
|
||||||
|
if (MainActivity.DEBUG) {
|
||||||
|
Log.d(TAG, "onPlaybackSpeedClicked() called")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player.videoPlayerSelected()) {
|
||||||
|
PlaybackParameterDialog.newInstance(
|
||||||
|
player.playbackSpeed.toDouble(),
|
||||||
|
player.playbackPitch.toDouble(),
|
||||||
|
player.playbackSkipSilence
|
||||||
|
) { speed: Float, pitch: Float, skipSilence: Boolean ->
|
||||||
|
player.setPlaybackParameters(
|
||||||
|
speed,
|
||||||
|
pitch,
|
||||||
|
skipSilence
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.show(player.parentActivity!!.supportFragmentManager, null)
|
||||||
|
} else {
|
||||||
|
playbackSpeedPopupMenu.show()
|
||||||
|
player.isSomePopupMenuVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
player.manageControlsAfterOnClick(v)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,41 @@
|
||||||
|
package org.schabi.newpipe.player.listeners.view
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import androidx.appcompat.widget.PopupMenu
|
||||||
|
import org.schabi.newpipe.MainActivity
|
||||||
|
import org.schabi.newpipe.extractor.MediaFormat
|
||||||
|
import org.schabi.newpipe.player.Player
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click listener for the qualityTextView of the player
|
||||||
|
*/
|
||||||
|
class QualityClickListener(
|
||||||
|
private val player: Player,
|
||||||
|
private val qualityPopupMenu: PopupMenu
|
||||||
|
) : View.OnClickListener {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG: String = "QualityClickListener"
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetTextI18n") // we don't need I18N because of a " "
|
||||||
|
override fun onClick(v: View) {
|
||||||
|
if (MainActivity.DEBUG) {
|
||||||
|
Log.d(TAG, "onQualitySelectorClicked() called")
|
||||||
|
}
|
||||||
|
|
||||||
|
qualityPopupMenu.show()
|
||||||
|
player.isSomePopupMenuVisible = true
|
||||||
|
|
||||||
|
val videoStream = player.selectedVideoStream
|
||||||
|
if (videoStream != null) {
|
||||||
|
player.binding.qualityTextView.text =
|
||||||
|
MediaFormat.getNameById(videoStream.formatId) + " " + videoStream.resolution
|
||||||
|
}
|
||||||
|
|
||||||
|
player.saveWasPlaying()
|
||||||
|
player.manageControlsAfterOnClick(v)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,99 @@
|
||||||
|
package org.schabi.newpipe.player.mediaitem;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||||
|
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This {@link MediaItemTag} object is designed to contain metadata for a stream
|
||||||
|
* that has failed to load. It supplies metadata from an underlying
|
||||||
|
* {@link PlayQueueItem}, which is used by the internal players to resolve actual
|
||||||
|
* playback info.
|
||||||
|
*
|
||||||
|
* This {@link MediaItemTag} does not contain any {@link StreamInfo} that can be
|
||||||
|
* used to start playback and can be detected by checking {@link ExceptionTag#getErrors()}
|
||||||
|
* when in generic form.
|
||||||
|
**/
|
||||||
|
public final class ExceptionTag implements MediaItemTag {
|
||||||
|
@NonNull
|
||||||
|
private final PlayQueueItem item;
|
||||||
|
@NonNull
|
||||||
|
private final List<Exception> errors;
|
||||||
|
@Nullable
|
||||||
|
private final Object extras;
|
||||||
|
|
||||||
|
private ExceptionTag(@NonNull final PlayQueueItem item,
|
||||||
|
@NonNull final List<Exception> errors,
|
||||||
|
@Nullable final Object extras) {
|
||||||
|
this.item = item;
|
||||||
|
this.errors = errors;
|
||||||
|
this.extras = extras;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ExceptionTag of(@NonNull final PlayQueueItem playQueueItem,
|
||||||
|
@NonNull final List<Exception> errors) {
|
||||||
|
return new ExceptionTag(playQueueItem, errors, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public List<Exception> getErrors() {
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getServiceId() {
|
||||||
|
return item.getServiceId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getTitle() {
|
||||||
|
return item.getTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getUploaderName() {
|
||||||
|
return item.getUploader();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getDurationSeconds() {
|
||||||
|
return item.getDuration();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getStreamUrl() {
|
||||||
|
return item.getUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getThumbnailUrl() {
|
||||||
|
return item.getThumbnailUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getUploaderUrl() {
|
||||||
|
return item.getUploaderUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public StreamType getStreamType() {
|
||||||
|
return item.getStreamType();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> Optional<T> getMaybeExtras(@NonNull final Class<T> type) {
|
||||||
|
return Optional.ofNullable(extras).map(type::cast);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> MediaItemTag withExtras(@NonNull final T extra) {
|
||||||
|
return new ExceptionTag(item, errors, extra);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,127 @@
|
||||||
|
package org.schabi.newpipe.player.mediaitem;
|
||||||
|
|
||||||
|
import android.net.Uri;
|
||||||
|
|
||||||
|
import com.google.android.exoplayer2.MediaItem;
|
||||||
|
import com.google.android.exoplayer2.MediaMetadata;
|
||||||
|
import com.google.android.exoplayer2.Player;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||||
|
import org.schabi.newpipe.extractor.stream.VideoStream;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata container and accessor used by player internals.
|
||||||
|
*
|
||||||
|
* This interface ensures consistency of fetching metadata on each stream,
|
||||||
|
* which is encapsulated in a {@link MediaItem} and delivered via ExoPlayer's
|
||||||
|
* {@link Player.Listener} on event triggers to the downstream users.
|
||||||
|
**/
|
||||||
|
public interface MediaItemTag {
|
||||||
|
|
||||||
|
List<Exception> getErrors();
|
||||||
|
|
||||||
|
int getServiceId();
|
||||||
|
|
||||||
|
String getTitle();
|
||||||
|
|
||||||
|
String getUploaderName();
|
||||||
|
|
||||||
|
long getDurationSeconds();
|
||||||
|
|
||||||
|
String getStreamUrl();
|
||||||
|
|
||||||
|
String getThumbnailUrl();
|
||||||
|
|
||||||
|
String getUploaderUrl();
|
||||||
|
|
||||||
|
StreamType getStreamType();
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
default Optional<StreamInfo> getMaybeStreamInfo() {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
default Optional<Quality> getMaybeQuality() {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
<T> Optional<T> getMaybeExtras(@NonNull Class<T> type);
|
||||||
|
|
||||||
|
<T> MediaItemTag withExtras(@NonNull T extra);
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
static Optional<MediaItemTag> from(@Nullable final MediaItem mediaItem) {
|
||||||
|
if (mediaItem == null || mediaItem.localConfiguration == null
|
||||||
|
|| !(mediaItem.localConfiguration.tag instanceof MediaItemTag)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Optional.of((MediaItemTag) mediaItem.localConfiguration.tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
default String makeMediaId() {
|
||||||
|
return UUID.randomUUID().toString() + "[" + getTitle() + "]";
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
default MediaItem asMediaItem() {
|
||||||
|
final MediaMetadata mediaMetadata = new MediaMetadata.Builder()
|
||||||
|
.setMediaUri(Uri.parse(getStreamUrl()))
|
||||||
|
.setArtworkUri(Uri.parse(getThumbnailUrl()))
|
||||||
|
.setArtist(getUploaderName())
|
||||||
|
.setDescription(getTitle())
|
||||||
|
.setDisplayTitle(getTitle())
|
||||||
|
.setTitle(getTitle())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return MediaItem.fromUri(getStreamUrl())
|
||||||
|
.buildUpon()
|
||||||
|
.setMediaId(makeMediaId())
|
||||||
|
.setMediaMetadata(mediaMetadata)
|
||||||
|
.setTag(this)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Quality {
|
||||||
|
@NonNull
|
||||||
|
private final List<VideoStream> sortedVideoStreams;
|
||||||
|
private final int selectedVideoStreamIndex;
|
||||||
|
|
||||||
|
private Quality(@NonNull final List<VideoStream> sortedVideoStreams,
|
||||||
|
final int selectedVideoStreamIndex) {
|
||||||
|
this.sortedVideoStreams = sortedVideoStreams;
|
||||||
|
this.selectedVideoStreamIndex = selectedVideoStreamIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Quality of(@NonNull final List<VideoStream> sortedVideoStreams,
|
||||||
|
final int selectedVideoStreamIndex) {
|
||||||
|
return new Quality(sortedVideoStreams, selectedVideoStreamIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public List<VideoStream> getSortedVideoStreams() {
|
||||||
|
return sortedVideoStreams;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getSelectedVideoStreamIndex() {
|
||||||
|
return selectedVideoStreamIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public VideoStream getSelectedVideoStream() {
|
||||||
|
return selectedVideoStreamIndex < 0
|
||||||
|
|| selectedVideoStreamIndex >= sortedVideoStreams.size()
|
||||||
|
? null : sortedVideoStreams.get(selectedVideoStreamIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,85 @@
|
||||||
|
package org.schabi.newpipe.player.mediaitem;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||||
|
import org.schabi.newpipe.util.Constants;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a Placeholding {@link MediaItemTag}, designed as a dummy metadata object for
|
||||||
|
* any stream that has not been resolved.
|
||||||
|
*
|
||||||
|
* This object cannot be instantiated and does not hold real metadata of any form.
|
||||||
|
* */
|
||||||
|
public final class PlaceholderTag implements MediaItemTag {
|
||||||
|
public static final PlaceholderTag EMPTY = new PlaceholderTag(null);
|
||||||
|
private static final String UNKNOWN_VALUE_INTERNAL = "Placeholder";
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private final Object extras;
|
||||||
|
|
||||||
|
private PlaceholderTag(@Nullable final Object extras) {
|
||||||
|
this.extras = extras;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public List<Exception> getErrors() {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getServiceId() {
|
||||||
|
return Constants.NO_SERVICE_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getTitle() {
|
||||||
|
return UNKNOWN_VALUE_INTERNAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getUploaderName() {
|
||||||
|
return UNKNOWN_VALUE_INTERNAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getDurationSeconds() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getStreamUrl() {
|
||||||
|
return UNKNOWN_VALUE_INTERNAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getThumbnailUrl() {
|
||||||
|
return UNKNOWN_VALUE_INTERNAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getUploaderUrl() {
|
||||||
|
return UNKNOWN_VALUE_INTERNAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public StreamType getStreamType() {
|
||||||
|
return StreamType.NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> Optional<T> getMaybeExtras(@NonNull final Class<T> type) {
|
||||||
|
return Optional.ofNullable(extras).map(type::cast);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> MediaItemTag withExtras(@NonNull final T extra) {
|
||||||
|
return new PlaceholderTag(extra);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,115 @@
|
||||||
|
package org.schabi.newpipe.player.mediaitem;
|
||||||
|
|
||||||
|
import com.google.android.exoplayer2.MediaItem;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||||
|
import org.schabi.newpipe.extractor.stream.VideoStream;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This {@link MediaItemTag} object contains metadata for a resolved stream
|
||||||
|
* that is ready for playback. This object guarantees the {@link StreamInfo}
|
||||||
|
* is available and may provide the {@link Quality} of video stream used in
|
||||||
|
* the {@link MediaItem}.
|
||||||
|
**/
|
||||||
|
public final class StreamInfoTag implements MediaItemTag {
|
||||||
|
@NonNull
|
||||||
|
private final StreamInfo streamInfo;
|
||||||
|
@Nullable
|
||||||
|
private final MediaItemTag.Quality quality;
|
||||||
|
@Nullable
|
||||||
|
private final Object extras;
|
||||||
|
|
||||||
|
private StreamInfoTag(@NonNull final StreamInfo streamInfo,
|
||||||
|
@Nullable final MediaItemTag.Quality quality,
|
||||||
|
@Nullable final Object extras) {
|
||||||
|
this.streamInfo = streamInfo;
|
||||||
|
this.quality = quality;
|
||||||
|
this.extras = extras;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StreamInfoTag of(@NonNull final StreamInfo streamInfo,
|
||||||
|
@NonNull final List<VideoStream> sortedVideoStreams,
|
||||||
|
final int selectedVideoStreamIndex) {
|
||||||
|
final Quality quality = Quality.of(sortedVideoStreams, selectedVideoStreamIndex);
|
||||||
|
return new StreamInfoTag(streamInfo, quality, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StreamInfoTag of(@NonNull final StreamInfo streamInfo) {
|
||||||
|
return new StreamInfoTag(streamInfo, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Exception> getErrors() {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getServiceId() {
|
||||||
|
return streamInfo.getServiceId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getTitle() {
|
||||||
|
return streamInfo.getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getUploaderName() {
|
||||||
|
return streamInfo.getUploaderName();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getDurationSeconds() {
|
||||||
|
return streamInfo.getDuration();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getStreamUrl() {
|
||||||
|
return streamInfo.getUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getThumbnailUrl() {
|
||||||
|
return streamInfo.getThumbnailUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getUploaderUrl() {
|
||||||
|
return streamInfo.getUploaderUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public StreamType getStreamType() {
|
||||||
|
return streamInfo.getStreamType();
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public Optional<StreamInfo> getMaybeStreamInfo() {
|
||||||
|
return Optional.of(streamInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public Optional<Quality> getMaybeQuality() {
|
||||||
|
return Optional.ofNullable(quality);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public <T> Optional<T> getMaybeExtras(@NonNull final Class<T> type) {
|
||||||
|
return Optional.ofNullable(extras).map(type::cast);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public StreamInfoTag withExtras(@NonNull final Object extra) {
|
||||||
|
return new StreamInfoTag(streamInfo, quality, extra);
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,7 +7,6 @@ import android.support.v4.media.session.MediaSessionCompat;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import com.google.android.exoplayer2.ControlDispatcher;
|
|
||||||
import com.google.android.exoplayer2.Player;
|
import com.google.android.exoplayer2.Player;
|
||||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||||
import com.google.android.exoplayer2.util.Util;
|
import com.google.android.exoplayer2.util.Util;
|
||||||
|
@ -44,17 +43,17 @@ public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onTimelineChanged(final Player player) {
|
public void onTimelineChanged(@NonNull final Player player) {
|
||||||
publishFloatingQueueWindow();
|
publishFloatingQueueWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCurrentWindowIndexChanged(final Player player) {
|
public void onCurrentMediaItemIndexChanged(@NonNull final Player player) {
|
||||||
if (activeQueueItemId == MediaSessionCompat.QueueItem.UNKNOWN_ID
|
if (activeQueueItemId == MediaSessionCompat.QueueItem.UNKNOWN_ID
|
||||||
|| player.getCurrentTimeline().getWindowCount() > maxQueueSize) {
|
|| player.getCurrentTimeline().getWindowCount() > maxQueueSize) {
|
||||||
publishFloatingQueueWindow();
|
publishFloatingQueueWindow();
|
||||||
} else if (!player.getCurrentTimeline().isEmpty()) {
|
} else if (!player.getCurrentTimeline().isEmpty()) {
|
||||||
activeQueueItemId = player.getCurrentWindowIndex();
|
activeQueueItemId = player.getCurrentMediaItemIndex();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,18 +63,17 @@ public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onSkipToPrevious(final Player player, final ControlDispatcher controlDispatcher) {
|
public void onSkipToPrevious(@NonNull final Player player) {
|
||||||
callback.playPrevious();
|
callback.playPrevious();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onSkipToQueueItem(final Player player, final ControlDispatcher controlDispatcher,
|
public void onSkipToQueueItem(@NonNull final Player player, final long id) {
|
||||||
final long id) {
|
|
||||||
callback.playItemAtIndex((int) id);
|
callback.playItemAtIndex((int) id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onSkipToNext(final Player player, final ControlDispatcher controlDispatcher) {
|
public void onSkipToNext(@NonNull final Player player) {
|
||||||
callback.playNext();
|
callback.playNext();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,8 +100,10 @@ public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onCommand(final Player player, final ControlDispatcher controlDispatcher,
|
public boolean onCommand(@NonNull final Player player,
|
||||||
final String command, final Bundle extras, final ResultReceiver cb) {
|
@NonNull final String command,
|
||||||
|
@Nullable final Bundle extras,
|
||||||
|
@Nullable final ResultReceiver cb) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
package org.schabi.newpipe.player.mediasession;
|
|
||||||
|
|
||||||
import com.google.android.exoplayer2.DefaultControlDispatcher;
|
|
||||||
import com.google.android.exoplayer2.Player;
|
|
||||||
|
|
||||||
public class PlayQueuePlaybackController extends DefaultControlDispatcher {
|
|
||||||
private final MediaSessionCallback callback;
|
|
||||||
|
|
||||||
public PlayQueuePlaybackController(final MediaSessionCallback callback) {
|
|
||||||
super();
|
|
||||||
this.callback = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean dispatchSetPlayWhenReady(final Player player, final boolean playWhenReady) {
|
|
||||||
if (playWhenReady) {
|
|
||||||
callback.play();
|
|
||||||
} else {
|
|
||||||
callback.pause();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,52 +2,83 @@ package org.schabi.newpipe.player.mediasource;
|
||||||
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
|
|
||||||
import com.google.android.exoplayer2.MediaItem;
|
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.BaseMediaSource;
|
||||||
import com.google.android.exoplayer2.source.MediaPeriod;
|
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.Allocator;
|
||||||
import com.google.android.exoplayer2.upstream.TransferListener;
|
import com.google.android.exoplayer2.upstream.TransferListener;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.player.mediaitem.ExceptionTag;
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||||
|
|
||||||
import java.io.IOException;
|
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 {
|
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 String TAG = "FailedMediaSource@" + Integer.toHexString(hashCode());
|
||||||
private final PlayQueueItem playQueueItem;
|
private final PlayQueueItem playQueueItem;
|
||||||
private final FailedMediaSourceException error;
|
private final Exception error;
|
||||||
private final long retryTimestamp;
|
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,
|
public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem,
|
||||||
@NonNull final FailedMediaSourceException error,
|
@NonNull final Exception error,
|
||||||
final long retryTimestamp) {
|
final long retryTimestamp) {
|
||||||
this.playQueueItem = playQueueItem;
|
this.playQueueItem = playQueueItem;
|
||||||
this.error = error;
|
this.error = error;
|
||||||
this.retryTimestamp = retryTimestamp;
|
this.retryTimestamp = retryTimestamp;
|
||||||
|
this.mediaItem = ExceptionTag
|
||||||
|
.of(playQueueItem, Collections.singletonList(error))
|
||||||
|
.withExtras(this)
|
||||||
|
.asMediaItem();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public static FailedMediaSource of(@NonNull final PlayQueueItem playQueueItem,
|
||||||
* Permanently fail the play queue item associated with this source, with no hope of retrying.
|
@NonNull final FailedMediaSourceException error) {
|
||||||
* The error will always be propagated to ExoPlayer.
|
return new FailedMediaSource(playQueueItem, error, Long.MAX_VALUE);
|
||||||
*
|
}
|
||||||
* @param playQueueItem play queue item
|
|
||||||
* @param error exception that was the reason to fail
|
public static FailedMediaSource of(@NonNull final PlayQueueItem playQueueItem,
|
||||||
*/
|
@NonNull final Exception error,
|
||||||
public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem,
|
final long retryWaitMillis) {
|
||||||
@NonNull final FailedMediaSourceException error) {
|
return new FailedMediaSource(playQueueItem, error,
|
||||||
this.playQueueItem = playQueueItem;
|
System.currentTimeMillis() + retryWaitMillis);
|
||||||
this.error = error;
|
|
||||||
this.retryTimestamp = Long.MAX_VALUE;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public PlayQueueItem getStream() {
|
public PlayQueueItem getStream() {
|
||||||
return playQueueItem;
|
return playQueueItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
public FailedMediaSourceException getError() {
|
public Exception getError() {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,35 +86,78 @@ public class FailedMediaSource extends BaseMediaSource implements ManagedMediaSo
|
||||||
return System.currentTimeMillis() >= retryTimestamp;
|
return System.currentTimeMillis() >= retryTimestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the {@link MediaItem} whose media is provided by the source.
|
|
||||||
*/
|
|
||||||
@Override
|
@Override
|
||||||
public MediaItem getMediaItem() {
|
public MediaItem getMediaItem() {
|
||||||
return MediaItem.fromUri(playQueueItem.getUrl());
|
return mediaItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
/**
|
||||||
public void maybeThrowSourceInfoRefreshError() throws IOException {
|
* Prepares the source with {@link Timeline} info on the silence playback when the error
|
||||||
throw new IOException(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
|
||||||
@Override
|
* exception is carried to the {@link MediaItem} metadata during playback.
|
||||||
public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator,
|
* <br><br>
|
||||||
final long startPositionUs) {
|
* If the exception is not known, e.g. {@link java.net.UnknownHostException} or some
|
||||||
return null;
|
* other network issue, then no source info is refreshed and
|
||||||
}
|
* {@link #maybeThrowSourceInfoRefreshError()} be will triggered.
|
||||||
|
* <br><br>
|
||||||
@Override
|
* Note that this method is called only once until {@link #releaseSourceInternal()} is called,
|
||||||
public void releasePeriod(final MediaPeriod mediaPeriod) { }
|
* 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
|
@Override
|
||||||
protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) {
|
protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) {
|
||||||
Log.e(TAG, "Loading failed source: ", error);
|
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
|
@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
|
@Override
|
||||||
public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity,
|
public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity,
|
||||||
|
@ -117,4 +191,22 @@ public class FailedMediaSource extends BaseMediaSource implements ManagedMediaSo
|
||||||
super(cause);
|
super(cause);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Timeline makeSilentMediaTimeline(final long durationUs,
|
||||||
|
@NonNull final MediaItem mediaItem) {
|
||||||
|
return new SinglePeriodTimeline(
|
||||||
|
durationUs,
|
||||||
|
/* isSeekable= */ true,
|
||||||
|
/* isDynamic= */ false,
|
||||||
|
/* useLiveConfiguration= */ false,
|
||||||
|
/* manifest= */ null,
|
||||||
|
mediaItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MediaPeriod makeSilentMediaPeriod(final long durationUs) {
|
||||||
|
return new SilenceMediaSource.Factory()
|
||||||
|
.setDurationUs(durationUs)
|
||||||
|
.createMediaSource()
|
||||||
|
.createPeriod(null, null, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,32 +1,46 @@
|
||||||
package org.schabi.newpipe.player.mediasource;
|
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.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import com.google.android.exoplayer2.MediaItem;
|
public class LoadedMediaSource extends CompositeMediaSource<Integer> implements ManagedMediaSource {
|
||||||
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 {
|
|
||||||
private final MediaSource source;
|
private final MediaSource source;
|
||||||
private final PlayQueueItem stream;
|
private final PlayQueueItem stream;
|
||||||
|
private final MediaItem mediaItem;
|
||||||
private final long expireTimestamp;
|
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) {
|
final long expireTimestamp) {
|
||||||
this.source = source;
|
this.source = source;
|
||||||
this.stream = stream;
|
this.stream = stream;
|
||||||
this.expireTimestamp = expireTimestamp;
|
this.expireTimestamp = expireTimestamp;
|
||||||
|
|
||||||
|
this.mediaItem = tag.withExtras(this).asMediaItem();
|
||||||
}
|
}
|
||||||
|
|
||||||
public PlayQueueItem getStream() {
|
public PlayQueueItem getStream() {
|
||||||
|
@ -37,20 +51,38 @@ public class LoadedMediaSource implements ManagedMediaSource {
|
||||||
return System.currentTimeMillis() >= expireTimestamp;
|
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
|
@Override
|
||||||
public void prepareSource(final MediaSourceCaller mediaSourceCaller,
|
protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) {
|
||||||
@Nullable final TransferListener mediaTransferListener) {
|
super.prepareSourceInternal(mediaTransferListener);
|
||||||
source.prepareSource(mediaSourceCaller, mediaTransferListener);
|
prepareChildSource(0, source);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When any child {@link MediaSource} is prepared, the refreshed {@link Timeline} can
|
||||||
|
* be listened to here. But since {@link LoadedMediaSource} has only a single child source,
|
||||||
|
* this method is called only once until {@link #releaseSourceInternal()} is called.
|
||||||
|
* <br><br>
|
||||||
|
* On refresh, the {@link CompositeMediaSource} delegate will be notified with the
|
||||||
|
* new {@link Timeline}, otherwise {@link #createPeriod(MediaPeriodId, Allocator, long)}
|
||||||
|
* will not be called and playback may be stalled.
|
||||||
|
*
|
||||||
|
* @param id The unique id used to prepare the child source.
|
||||||
|
* @param mediaSource The child source whose source info has been refreshed.
|
||||||
|
* @param timeline The new timeline of the child source.
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void maybeThrowSourceInfoRefreshError() throws IOException {
|
protected void onChildSourceInfoRefreshed(final Integer id,
|
||||||
source.maybeThrowSourceInfoRefreshError();
|
final MediaSource mediaSource,
|
||||||
}
|
final Timeline timeline) {
|
||||||
|
refreshSourceInfo(timeline);
|
||||||
@Override
|
|
||||||
public void enable(final MediaSourceCaller caller) {
|
|
||||||
source.enable(caller);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -64,57 +96,10 @@ public class LoadedMediaSource implements ManagedMediaSource {
|
||||||
source.releasePeriod(mediaPeriod);
|
source.releasePeriod(mediaPeriod);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@NonNull
|
||||||
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.
|
|
||||||
*/
|
|
||||||
@Override
|
@Override
|
||||||
public MediaItem getMediaItem() {
|
public MediaItem getMediaItem() {
|
||||||
return source.getMediaItem();
|
return mediaItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package org.schabi.newpipe.player.mediasource;
|
package org.schabi.newpipe.player.mediasource;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
|
|
||||||
import com.google.android.exoplayer2.source.MediaSource;
|
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
|
* @return whether this source is for the specified stream
|
||||||
*/
|
*/
|
||||||
boolean isStreamEqual(@NonNull PlayQueueItem stream);
|
boolean isStreamEqual(@NonNull PlayQueueItem stream);
|
||||||
|
|
||||||
@Nullable
|
|
||||||
@Override
|
|
||||||
default Object getTag() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,8 @@ import androidx.annotation.Nullable;
|
||||||
import com.google.android.exoplayer2.source.ConcatenatingMediaSource;
|
import com.google.android.exoplayer2.source.ConcatenatingMediaSource;
|
||||||
import com.google.android.exoplayer2.source.ShuffleOrder;
|
import com.google.android.exoplayer2.source.ShuffleOrder;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.player.mediaitem.MediaItemTag;
|
||||||
|
|
||||||
public class ManagedMediaSourcePlaylist {
|
public class ManagedMediaSourcePlaylist {
|
||||||
@NonNull
|
@NonNull
|
||||||
private final ConcatenatingMediaSource internalSource;
|
private final ConcatenatingMediaSource internalSource;
|
||||||
|
@ -34,8 +36,14 @@ public class ManagedMediaSourcePlaylist {
|
||||||
*/
|
*/
|
||||||
@Nullable
|
@Nullable
|
||||||
public ManagedMediaSource get(final int index) {
|
public ManagedMediaSource get(final int index) {
|
||||||
return (index < 0 || index >= size())
|
if (index < 0 || index >= size()) {
|
||||||
? null : (ManagedMediaSource) internalSource.getMediaSource(index).getTag();
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaItemTag
|
||||||
|
.from(internalSource.getMediaSource(index).getMediaItem())
|
||||||
|
.flatMap(tag -> tag.getMaybeExtras(ManagedMediaSource.class))
|
||||||
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
|
@ -54,7 +62,7 @@ public class ManagedMediaSourcePlaylist {
|
||||||
* @see #append(ManagedMediaSource)
|
* @see #append(ManagedMediaSource)
|
||||||
*/
|
*/
|
||||||
public synchronized void expand() {
|
public synchronized void expand() {
|
||||||
append(new PlaceholderMediaSource());
|
append(PlaceholderMediaSource.COPY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -115,10 +123,10 @@ public class ManagedMediaSourcePlaylist {
|
||||||
public synchronized void invalidate(final int index,
|
public synchronized void invalidate(final int index,
|
||||||
@Nullable final Handler handler,
|
@Nullable final Handler handler,
|
||||||
@Nullable final Runnable finalizingAction) {
|
@Nullable final Runnable finalizingAction) {
|
||||||
if (get(index) instanceof PlaceholderMediaSource) {
|
if (get(index) == PlaceholderMediaSource.COPY) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
update(index, new PlaceholderMediaSource(), handler, finalizingAction);
|
update(index, PlaceholderMediaSource.COPY, handler, finalizingAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,28 +1,35 @@
|
||||||
package org.schabi.newpipe.player.mediasource;
|
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.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.MediaPeriod;
|
||||||
|
import com.google.android.exoplayer2.source.MediaSource;
|
||||||
import com.google.android.exoplayer2.upstream.Allocator;
|
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;
|
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||||
|
|
||||||
public class PlaceholderMediaSource extends BaseMediaSource implements ManagedMediaSource {
|
import androidx.annotation.NonNull;
|
||||||
/**
|
|
||||||
* Returns the {@link MediaItem} whose media is provided by the source.
|
final class PlaceholderMediaSource
|
||||||
*/
|
extends CompositeMediaSource<Void> implements ManagedMediaSource {
|
||||||
|
public static final PlaceholderMediaSource COPY = new PlaceholderMediaSource();
|
||||||
|
private static final MediaItem MEDIA_ITEM = PlaceholderTag.EMPTY.withExtras(COPY).asMediaItem();
|
||||||
|
|
||||||
|
private PlaceholderMediaSource() { }
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public MediaItem getMediaItem() {
|
public MediaItem getMediaItem() {
|
||||||
return null;
|
return MEDIA_ITEM;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do nothing, so this will stall the playback
|
|
||||||
@Override
|
@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
|
@Override
|
||||||
public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator,
|
public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator,
|
||||||
|
@ -33,12 +40,6 @@ public class PlaceholderMediaSource extends BaseMediaSource implements ManagedMe
|
||||||
@Override
|
@Override
|
||||||
public void releasePeriod(final MediaPeriod mediaPeriod) { }
|
public void releasePeriod(final MediaPeriod mediaPeriod) { }
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) { }
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void releaseSourceInternal() { }
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity,
|
public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity,
|
||||||
final boolean isInterruptable) {
|
final boolean isInterruptable) {
|
||||||
|
|
|
@ -1,92 +0,0 @@
|
||||||
package org.schabi.newpipe.player.playback;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.text.TextUtils;
|
|
||||||
import android.util.Pair;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
|
|
||||||
import com.google.android.exoplayer2.C;
|
|
||||||
import com.google.android.exoplayer2.Format;
|
|
||||||
import com.google.android.exoplayer2.RendererCapabilities.Capabilities;
|
|
||||||
import com.google.android.exoplayer2.source.TrackGroup;
|
|
||||||
import com.google.android.exoplayer2.source.TrackGroupArray;
|
|
||||||
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
|
|
||||||
import com.google.android.exoplayer2.trackselection.ExoTrackSelection;
|
|
||||||
import com.google.android.exoplayer2.util.Assertions;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This class allows irregular text language labels for use when selecting text captions and
|
|
||||||
* is mostly a copy-paste from {@link DefaultTrackSelector}.
|
|
||||||
* <p>
|
|
||||||
* This is a hack and should be removed once ExoPlayer fixes language normalization to accept
|
|
||||||
* a broader set of languages.
|
|
||||||
* </p>
|
|
||||||
*/
|
|
||||||
public class CustomTrackSelector extends DefaultTrackSelector {
|
|
||||||
private String preferredTextLanguage;
|
|
||||||
|
|
||||||
public CustomTrackSelector(final Context context,
|
|
||||||
final ExoTrackSelection.Factory adaptiveTrackSelectionFactory) {
|
|
||||||
super(context, adaptiveTrackSelectionFactory);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean formatHasLanguage(final Format format, final String language) {
|
|
||||||
return language != null && TextUtils.equals(language, format.language);
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getPreferredTextLanguage() {
|
|
||||||
return preferredTextLanguage;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setPreferredTextLanguage(@NonNull final String label) {
|
|
||||||
Assertions.checkNotNull(label);
|
|
||||||
if (!label.equals(preferredTextLanguage)) {
|
|
||||||
preferredTextLanguage = label;
|
|
||||||
invalidate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Nullable
|
|
||||||
protected Pair<ExoTrackSelection.Definition, TextTrackScore> selectTextTrack(
|
|
||||||
final TrackGroupArray groups,
|
|
||||||
@NonNull final int[][] formatSupport,
|
|
||||||
@NonNull final Parameters params,
|
|
||||||
@Nullable final String selectedAudioLanguage) {
|
|
||||||
TrackGroup selectedGroup = null;
|
|
||||||
int selectedTrackIndex = C.INDEX_UNSET;
|
|
||||||
TextTrackScore selectedTrackScore = null;
|
|
||||||
|
|
||||||
for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) {
|
|
||||||
final TrackGroup trackGroup = groups.get(groupIndex);
|
|
||||||
@Capabilities final int[] trackFormatSupport = formatSupport[groupIndex];
|
|
||||||
|
|
||||||
for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) {
|
|
||||||
if (isSupported(trackFormatSupport[trackIndex],
|
|
||||||
params.exceedRendererCapabilitiesIfNecessary)) {
|
|
||||||
final Format format = trackGroup.getFormat(trackIndex);
|
|
||||||
final TextTrackScore trackScore = new TextTrackScore(format, params,
|
|
||||||
trackFormatSupport[trackIndex], selectedAudioLanguage);
|
|
||||||
|
|
||||||
if (formatHasLanguage(format, preferredTextLanguage)) {
|
|
||||||
selectedGroup = trackGroup;
|
|
||||||
selectedTrackIndex = trackIndex;
|
|
||||||
selectedTrackScore = trackScore;
|
|
||||||
break; // found user selected match (perfect!)
|
|
||||||
|
|
||||||
} else if (trackScore.isWithinConstraints && (selectedTrackScore == null
|
|
||||||
|| trackScore.compareTo(selectedTrackScore) > 0)) {
|
|
||||||
selectedGroup = trackGroup;
|
|
||||||
selectedTrackIndex = trackIndex;
|
|
||||||
selectedTrackScore = trackScore;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return selectedGroup == null ? null
|
|
||||||
: Pair.create(new ExoTrackSelection.Definition(selectedGroup, selectedTrackIndex),
|
|
||||||
Assertions.checkNotNull(selectedTrackScore));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -12,11 +12,12 @@ import com.google.android.exoplayer2.source.MediaSource;
|
||||||
|
|
||||||
import org.reactivestreams.Subscriber;
|
import org.reactivestreams.Subscriber;
|
||||||
import org.reactivestreams.Subscription;
|
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.FailedMediaSource;
|
||||||
import org.schabi.newpipe.player.mediasource.LoadedMediaSource;
|
import org.schabi.newpipe.player.mediasource.LoadedMediaSource;
|
||||||
import org.schabi.newpipe.player.mediasource.ManagedMediaSource;
|
import org.schabi.newpipe.player.mediasource.ManagedMediaSource;
|
||||||
import org.schabi.newpipe.player.mediasource.ManagedMediaSourcePlaylist;
|
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.PlayQueue;
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||||
import org.schabi.newpipe.player.playqueue.events.MoveEvent;
|
import org.schabi.newpipe.player.playqueue.events.MoveEvent;
|
||||||
|
@ -202,7 +203,7 @@ public class MediaSourceManager {
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
private Subscriber<PlayQueueEvent> getReactor() {
|
private Subscriber<PlayQueueEvent> getReactor() {
|
||||||
return new Subscriber<PlayQueueEvent>() {
|
return new Subscriber<>() {
|
||||||
@Override
|
@Override
|
||||||
public void onSubscribe(@NonNull final Subscription d) {
|
public void onSubscribe(@NonNull final Subscription d) {
|
||||||
playQueueReactor.cancel();
|
playQueueReactor.cancel();
|
||||||
|
@ -216,10 +217,12 @@ public class MediaSourceManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onError(@NonNull final Throwable e) { }
|
public void onError(@NonNull final Throwable e) {
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onComplete() { }
|
public void onComplete() {
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -299,11 +302,11 @@ public class MediaSourceManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
final ManagedMediaSource mediaSource = playlist.get(playQueue.getIndex());
|
final ManagedMediaSource mediaSource = playlist.get(playQueue.getIndex());
|
||||||
if (mediaSource == null) {
|
final PlayQueueItem playQueueItem = playQueue.getItem();
|
||||||
|
if (mediaSource == null || playQueueItem == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final PlayQueueItem playQueueItem = playQueue.getItem();
|
|
||||||
return mediaSource.isStreamEqual(playQueueItem);
|
return mediaSource.isStreamEqual(playQueueItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -322,7 +325,7 @@ public class MediaSourceManager {
|
||||||
isBlocked.set(true);
|
isBlocked.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void maybeUnblock() {
|
private boolean maybeUnblock() {
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
Log.d(TAG, "maybeUnblock() called.");
|
Log.d(TAG, "maybeUnblock() called.");
|
||||||
}
|
}
|
||||||
|
@ -330,14 +333,17 @@ public class MediaSourceManager {
|
||||||
if (isBlocked.get()) {
|
if (isBlocked.get()) {
|
||||||
isBlocked.set(false);
|
isBlocked.set(false);
|
||||||
playbackListener.onPlaybackUnblock(playlist.getParentMediaSource());
|
playbackListener.onPlaybackUnblock(playlist.getParentMediaSource());
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
// Metadata Synchronization
|
// Metadata Synchronization
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
private void maybeSync() {
|
private void maybeSync(final boolean wasBlocked) {
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
Log.d(TAG, "maybeSync() called.");
|
Log.d(TAG, "maybeSync() called.");
|
||||||
}
|
}
|
||||||
|
@ -347,13 +353,13 @@ public class MediaSourceManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
playbackListener.onPlaybackSynchronize(currentItem);
|
playbackListener.onPlaybackSynchronize(currentItem, wasBlocked);
|
||||||
}
|
}
|
||||||
|
|
||||||
private synchronized void maybeSynchronizePlayer() {
|
private synchronized void maybeSynchronizePlayer() {
|
||||||
if (isPlayQueueReady() && isPlaybackReady()) {
|
if (isPlayQueueReady() && isPlaybackReady()) {
|
||||||
maybeUnblock();
|
final boolean isBlockReleased = maybeUnblock();
|
||||||
maybeSync();
|
maybeSync(isBlockReleased);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -424,23 +430,32 @@ public class MediaSourceManager {
|
||||||
private Single<ManagedMediaSource> getLoadedMediaSource(@NonNull final PlayQueueItem stream) {
|
private Single<ManagedMediaSource> getLoadedMediaSource(@NonNull final PlayQueueItem stream) {
|
||||||
return stream.getStream().map(streamInfo -> {
|
return stream.getStream().map(streamInfo -> {
|
||||||
final MediaSource source = playbackListener.sourceOf(stream, 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. "
|
final String message = "Unable to resolve source from stream info. "
|
||||||
+ "URL: " + stream.getUrl() + ", "
|
+ "URL: " + stream.getUrl() + ", "
|
||||||
+ "audio count: " + streamInfo.getAudioStreams().size() + ", "
|
+ "audio count: " + streamInfo.getAudioStreams().size() + ", "
|
||||||
+ "video count: " + streamInfo.getVideoOnlyStreams().size() + ", "
|
+ "video count: " + streamInfo.getVideoOnlyStreams().size() + ", "
|
||||||
+ streamInfo.getVideoStreams().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()
|
final long expiration = System.currentTimeMillis()
|
||||||
+ ServiceHelper.getCacheExpirationMillis(streamInfo.getServiceId());
|
+ ServiceHelper.getCacheExpirationMillis(streamInfo.getServiceId());
|
||||||
|
|
||||||
stream.setVideoSegments(SponsorBlockUtils.getYouTubeVideoSegments(context, streamInfo));
|
stream.setVideoSegments(SponsorBlockUtils.getYouTubeVideoSegments(context, streamInfo));
|
||||||
|
|
||||||
return new LoadedMediaSource(source, stream, expiration);
|
return new LoadedMediaSource(source, tag, stream, expiration);
|
||||||
}).onErrorReturn(throwable -> new FailedMediaSource(stream,
|
}).onErrorReturn(throwable -> {
|
||||||
new StreamInfoLoadException(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,
|
private void onMediaSourceReceived(@NonNull final PlayQueueItem item,
|
||||||
|
@ -488,23 +503,23 @@ public class MediaSourceManager {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the current playing index contains an expired {@link ManagedMediaSource}.
|
* 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.
|
* {@link #loadImmediate()} is called to reload the current item.
|
||||||
* <br><br>
|
* <br><br>
|
||||||
* If not, then the media source at the current index is ready for playback, and
|
* If not, then the media source at the current index is ready for playback, and
|
||||||
* {@link #maybeSynchronizePlayer()} is called.
|
* {@link #maybeSynchronizePlayer()} is called.
|
||||||
* <br><br>
|
* <br><br>
|
||||||
* Under both cases, {@link #maybeSync()} will be called to ensure the listener
|
* Under both cases, {@link #maybeSync(boolean)} will be called to ensure the listener
|
||||||
* is up-to-date.
|
* is up-to-date.
|
||||||
*/
|
*/
|
||||||
private void maybeRenewCurrentIndex() {
|
private void maybeRenewCurrentIndex() {
|
||||||
final int currentIndex = playQueue.getIndex();
|
final int currentIndex = playQueue.getIndex();
|
||||||
|
final PlayQueueItem currentItem = playQueue.getItem();
|
||||||
final ManagedMediaSource currentSource = playlist.get(currentIndex);
|
final ManagedMediaSource currentSource = playlist.get(currentIndex);
|
||||||
if (currentSource == null) {
|
if (currentItem == null || currentSource == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final PlayQueueItem currentItem = playQueue.getItem();
|
|
||||||
if (!currentSource.shouldBeReplacedWith(currentItem, true)) {
|
if (!currentSource.shouldBeReplacedWith(currentItem, true)) {
|
||||||
maybeSynchronizePlayer();
|
maybeSynchronizePlayer();
|
||||||
return;
|
return;
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue