Merge branch 'sponsorblock' into preview-category
This commit is contained in:
commit
51c862498f
713 changed files with 15220 additions and 7448 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>.
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# NewPipe x SponsorBlock
|
# NewPipe x SponsorBlock x Return YouTube Dislike
|
||||||
A fork of [NewPipe](https://github.com/TeamNewPipe/NewPipe) with [SponsorBlock](https://sponsor.ajay.app/) functionality.
|
A fork of [NewPipe](https://github.com/TeamNewPipe/NewPipe) with [SponsorBlock](https://sponsor.ajay.app/) and [Return YouTube Dislike](https://returnyoutubedislike.com/) functionality.
|
||||||
|
|
||||||
![01](.github/images/preview01.gif)
|
![01](.github/images/preview01.gif)
|
||||||
![02](.github/images/preview02.gif)
|
![02](.github/images/preview02.gif)
|
||||||
|
@ -12,9 +12,9 @@ The implementation is still a bit basic but it generally works pretty well.
|
||||||
Builds will be uploaded in the [Releases](https://github.com/polymorphicshade/NewPipe/releases) section. Please download the APK from the newest release and install it on your device.
|
Builds will be uploaded in the [Releases](https://github.com/polymorphicshade/NewPipe/releases) section. Please download the APK from the newest release and install it on your device.
|
||||||
|
|
||||||
## Why isn't this in upstream NewPipe?
|
## Why isn't this in upstream NewPipe?
|
||||||
[The developer team](https://github.com/TeamNewPipe) behind the official NewPipe decided that they do not want to include this kind of functionality in their app. See https://newpipe.schabi.org/blog/pinned/newpipe-and-online-advertising/ and https://github.com/TeamNewPipe/NewPipe/pull/3205 for more information and discussion.
|
[The developer team](https://github.com/TeamNewPipe) behind the official NewPipe decided that they do not want to include these kinds of functionality in their app. See https://newpipe.schabi.org/blog/pinned/newpipe-and-online-advertising/, https://github.com/TeamNewPipe/NewPipe/pull/3205, and https://github.com/TeamNewPipe/NewPipe/issues/7469 for more information and discussion.
|
||||||
|
|
||||||
We obviously disagree but we respect their decision and continue to offer SponsorBlock in NewPipe via this fork.
|
We obviously disagree but we respect their decision and continue to offer SponsorBlock and Return YouTube Dislike in NewPipe via this fork.
|
||||||
|
|
||||||
## Bugs
|
## Bugs
|
||||||
Please do not report bugs encountered while using this fork to the upstream developers. Either try to reproduce the bug in vanilla NewPipe and then report it (preferred) or [create a bug report in our repo](https://github.com/polymorphicshade/NewPipe/issues/new?assignees=&labels=bug&template=bug_report.md).
|
Please do not report bugs encountered while using this fork to the upstream developers. Either try to reproduce the bug in vanilla NewPipe and then report it (preferred) or [create a bug report in our repo](https://github.com/polymorphicshade/NewPipe/issues/new?assignees=&labels=bug&template=bug_report.md).
|
||||||
|
|
|
@ -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 984
|
versionCode 987
|
||||||
versionName "0.22.1"
|
versionName "0.23.1"
|
||||||
|
|
||||||
multiDexEnabled true
|
multiDexEnabled true
|
||||||
|
|
||||||
|
@ -98,15 +98,16 @@ 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.1'
|
||||||
markwonVersion = '4.6.2'
|
markwonVersion = '4.6.2'
|
||||||
|
|
||||||
leakCanaryVersion = '2.5'
|
leakCanaryVersion = '2.5'
|
||||||
|
@ -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:v0.21.14'
|
implementation 'com.github.TeamNewPipe:NewPipeExtractor:5219a705bab539cf8c6624d0cec216e76e85f0b1'
|
||||||
|
|
||||||
/** 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,9 @@ 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 "androidx.work:work-runtime-ktx:${androidxWorkVersion}"
|
||||||
|
implementation "androidx.work:work-rxjava3:${androidxWorkVersion}"
|
||||||
|
implementation 'com.google.android.material:material:1.5.0'
|
||||||
|
|
||||||
/** Third-party libraries **/
|
/** Third-party libraries **/
|
||||||
// Instance state boilerplate elimination
|
// Instance state boilerplate elimination
|
||||||
|
@ -246,8 +249,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"
|
||||||
|
@ -260,7 +261,7 @@ dependencies {
|
||||||
implementation "com.nononsenseapps:filepicker:4.2.1"
|
implementation "com.nononsenseapps:filepicker:4.2.1"
|
||||||
|
|
||||||
// Crash reporting
|
// Crash reporting
|
||||||
implementation "ch.acra:acra-core:5.8.4"
|
implementation "ch.acra:acra-core:5.9.3"
|
||||||
|
|
||||||
// Properly restarting
|
// Properly restarting
|
||||||
implementation 'com.jakewharton:process-phoenix:2.1.2'
|
implementation 'com.jakewharton:process-phoenix:2.1.2'
|
||||||
|
|
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,214 @@
|
||||||
|
package org.schabi.newpipe.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.SparseArray
|
||||||
|
import android.view.View
|
||||||
|
import android.view.View.GONE
|
||||||
|
import android.view.View.INVISIBLE
|
||||||
|
import android.view.View.VISIBLE
|
||||||
|
import android.widget.Spinner
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.MediumTest
|
||||||
|
import androidx.test.internal.runner.junit4.statement.UiThreadStatement
|
||||||
|
import org.junit.Assert
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.extractor.MediaFormat
|
||||||
|
import org.schabi.newpipe.extractor.stream.AudioStream
|
||||||
|
import org.schabi.newpipe.extractor.stream.Stream
|
||||||
|
import org.schabi.newpipe.extractor.stream.SubtitlesStream
|
||||||
|
import org.schabi.newpipe.extractor.stream.VideoStream
|
||||||
|
|
||||||
|
@MediumTest
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class StreamItemAdapterTest {
|
||||||
|
private lateinit var context: Context
|
||||||
|
private lateinit var spinner: Spinner
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
context = ApplicationProvider.getApplicationContext()
|
||||||
|
UiThreadStatement.runOnUiThread {
|
||||||
|
spinner = Spinner(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun videoStreams_noSecondaryStream() {
|
||||||
|
val adapter = StreamItemAdapter<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.Builder()
|
||||||
|
.setContent("https://example.com", true)
|
||||||
|
.setMediaFormat(MediaFormat.SRT)
|
||||||
|
.setLanguageCode("pt-BR")
|
||||||
|
.setAutoGenerated(false)
|
||||||
|
.build()
|
||||||
|
},
|
||||||
|
context
|
||||||
|
),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
spinner.adapter = adapter
|
||||||
|
for (i in 0 until spinner.count) {
|
||||||
|
assertIconVisibility(spinner, i, GONE, GONE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun audioStreams_noIcon() {
|
||||||
|
val adapter = StreamItemAdapter<AudioStream, Stream>(
|
||||||
|
context,
|
||||||
|
StreamItemAdapter.StreamSizeWrapper(
|
||||||
|
(0 until 5).map {
|
||||||
|
AudioStream.Builder()
|
||||||
|
.setId(Stream.ID_UNKNOWN)
|
||||||
|
.setContent("https://example.com/$it", true)
|
||||||
|
.setMediaFormat(MediaFormat.OPUS)
|
||||||
|
.setAverageBitrate(192)
|
||||||
|
.build()
|
||||||
|
},
|
||||||
|
context
|
||||||
|
),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
spinner.adapter = adapter
|
||||||
|
for (i in 0 until spinner.count) {
|
||||||
|
assertIconVisibility(spinner, i, GONE, GONE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return a list of video streams, in which their video only property mirrors the provided
|
||||||
|
* [videoOnly] vararg.
|
||||||
|
*/
|
||||||
|
private fun getVideoStreams(vararg videoOnly: Boolean) =
|
||||||
|
StreamItemAdapter.StreamSizeWrapper(
|
||||||
|
videoOnly.map {
|
||||||
|
VideoStream.Builder()
|
||||||
|
.setId(Stream.ID_UNKNOWN)
|
||||||
|
.setContent("https://example.com", true)
|
||||||
|
.setMediaFormat(MediaFormat.MPEG_4)
|
||||||
|
.setResolution("720p")
|
||||||
|
.setIsVideoOnly(it)
|
||||||
|
.build()
|
||||||
|
},
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return a list of audio streams, containing valid and null elements mirroring the provided
|
||||||
|
* [shouldBeValid] vararg.
|
||||||
|
*/
|
||||||
|
private fun getAudioStreams(vararg shouldBeValid: Boolean) =
|
||||||
|
getSecondaryStreamsFromList(
|
||||||
|
shouldBeValid.map {
|
||||||
|
if (it) {
|
||||||
|
AudioStream.Builder()
|
||||||
|
.setId(Stream.ID_UNKNOWN)
|
||||||
|
.setContent("https://example.com", true)
|
||||||
|
.setMediaFormat(MediaFormat.OPUS)
|
||||||
|
.setAverageBitrate(192)
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the item at [position] in the [spinner] has the correct icon visibility when
|
||||||
|
* it is shown in normal mode (selected) and in dropdown mode (user is choosing one of a list).
|
||||||
|
*/
|
||||||
|
private fun assertIconVisibility(
|
||||||
|
spinner: Spinner,
|
||||||
|
position: Int,
|
||||||
|
normalVisibility: Int,
|
||||||
|
dropDownVisibility: Int
|
||||||
|
) {
|
||||||
|
spinner.setSelection(position)
|
||||||
|
spinner.adapter.getView(position, null, spinner).run {
|
||||||
|
Assert.assertEquals(
|
||||||
|
"normal visibility (pos=[$position]) is not correct",
|
||||||
|
findViewById<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;
|
||||||
|
|
||||||
|
@ -205,7 +205,7 @@ public class App extends MultiDexApplication {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final CoreConfigurationBuilder acraConfig = new CoreConfigurationBuilder(this)
|
final CoreConfigurationBuilder acraConfig = new CoreConfigurationBuilder()
|
||||||
.withBuildConfigClass(BuildConfig.class);
|
.withBuildConfigClass(BuildConfig.class);
|
||||||
ACRA.init(this, acraConfig);
|
ACRA.init(this, acraConfig);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -43,7 +43,7 @@ import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||||
|
|
||||||
public final class DownloaderImpl extends Downloader {
|
public final class DownloaderImpl extends Downloader {
|
||||||
public static final String USER_AGENT
|
public static final String USER_AGENT
|
||||||
= "Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.0";
|
= "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0";
|
||||||
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY
|
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY
|
||||||
= "youtube_restricted_mode_key";
|
= "youtube_restricted_mode_key";
|
||||||
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000";
|
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000";
|
||||||
|
|
|
@ -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
|
|
||||||
|
final App app = App.getApp();
|
||||||
|
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
|
||||||
|
|
||||||
|
if (prefs.getBoolean(app.getString(R.string.update_app_key), true)) {
|
||||||
|
// Start the worker which is checking all conditions
|
||||||
// and eventually searching for a new version.
|
// and eventually searching for a new version.
|
||||||
// The service searching for a new NewPipe version must not be started in background.
|
NewVersionWorker.enqueueNewVersionCheckingWork(app);
|
||||||
checkNewVersion();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
166
app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt
Normal file
166
app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
package org.schabi.newpipe
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import androidx.work.OneTimeWorkRequest
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkRequest
|
||||||
|
import androidx.work.Worker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.grack.nanojson.JsonParser
|
||||||
|
import com.grack.nanojson.JsonParserException
|
||||||
|
import org.schabi.newpipe.extractor.downloader.Response
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||||
|
import org.schabi.newpipe.util.ReleaseVersionUtil.coerceUpdateCheckExpiry
|
||||||
|
import org.schabi.newpipe.util.ReleaseVersionUtil.isLastUpdateCheckExpired
|
||||||
|
import org.schabi.newpipe.util.ReleaseVersionUtil.isReleaseApk
|
||||||
|
import org.schabi.newpipe.util.Version
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class NewVersionWorker(
|
||||||
|
context: Context,
|
||||||
|
workerParams: WorkerParameters
|
||||||
|
) : Worker(context, workerParams) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method to compare the current and latest available app version.
|
||||||
|
* If a newer version is available, we show the update notification.
|
||||||
|
*
|
||||||
|
* @param versionName Name of new version
|
||||||
|
* @param apkLocationUrl Url with the new apk
|
||||||
|
*/
|
||||||
|
private fun compareAppVersionAndShowNotification(
|
||||||
|
versionName: String,
|
||||||
|
apkLocationUrl: String?
|
||||||
|
) {
|
||||||
|
val sourceVersion = Version.fromString(BuildConfig.VERSION_NAME)
|
||||||
|
val targetVersion = Version.fromString(versionName)
|
||||||
|
|
||||||
|
// abort if source version is the same or newer than target version
|
||||||
|
if (sourceVersion >= targetVersion) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val app = App.getApp()
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, apkLocationUrl?.toUri())
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(app, 0, intent, 0)
|
||||||
|
val channelId = app.getString(R.string.app_update_notification_channel_id)
|
||||||
|
val notificationBuilder = NotificationCompat.Builder(app, channelId)
|
||||||
|
.setSmallIcon(R.drawable.ic_newpipe_update)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentTitle(app.getString(R.string.app_update_notification_content_title))
|
||||||
|
.setContentText(
|
||||||
|
app.getString(R.string.app_update_notification_content_text) +
|
||||||
|
" " + versionName
|
||||||
|
)
|
||||||
|
val notificationManager = NotificationManagerCompat.from(app)
|
||||||
|
notificationManager.notify(2000, notificationBuilder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class, ReCaptchaException::class)
|
||||||
|
private fun checkNewVersion() {
|
||||||
|
// Check if the current apk is a github one or not.
|
||||||
|
if (!isReleaseApk()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||||
|
// Check if the last request has happened a certain time ago
|
||||||
|
// to reduce the number of API requests.
|
||||||
|
val expiry = prefs.getLong(applicationContext.getString(R.string.update_expiry_key), 0)
|
||||||
|
if (!isLastUpdateCheckExpired(expiry)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a network request to get latest NewPipe data.
|
||||||
|
val response = DownloaderImpl.getInstance().get(API_URL)
|
||||||
|
handleResponse(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleResponse(response: Response) {
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||||
|
try {
|
||||||
|
// Store a timestamp which needs to be exceeded,
|
||||||
|
// before a new request to the API is made.
|
||||||
|
val newExpiry = coerceUpdateCheckExpiry(response.getHeader("expires"))
|
||||||
|
prefs.edit {
|
||||||
|
putLong(applicationContext.getString(R.string.update_expiry_key), newExpiry)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.w(TAG, "Could not extract and save new expiry date", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the json from the response.
|
||||||
|
try {
|
||||||
|
val jObj = JsonParser.`object`().from(response.responseBody())
|
||||||
|
val versionName = jObj.getString("tag_name")
|
||||||
|
val apkLocationUrl = jObj
|
||||||
|
.getArray("assets")
|
||||||
|
.getObject(0)
|
||||||
|
.getString("browser_download_url")
|
||||||
|
compareAppVersionAndShowNotification(versionName, apkLocationUrl)
|
||||||
|
} catch (e: JsonParserException) {
|
||||||
|
// Most likely something is wrong in data received from API_URL.
|
||||||
|
// Do not alarm user and fail silently.
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.w(TAG, "Could not get Github API: invalid json", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun doWork(): Result {
|
||||||
|
try {
|
||||||
|
checkNewVersion()
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e)
|
||||||
|
return Result.failure()
|
||||||
|
} catch (e: ReCaptchaException) {
|
||||||
|
Log.e(TAG, "ReCaptchaException should never happen here.", e)
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val DEBUG = MainActivity.DEBUG
|
||||||
|
private val TAG = NewVersionWorker::class.java.simpleName
|
||||||
|
private const val API_URL =
|
||||||
|
"https://api.github.com/repos/polymorphicshade/NewPipe/releases/latest"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a new worker which
|
||||||
|
* checks if all conditions for performing a version check are met,
|
||||||
|
* fetches the API endpoint [.NEWPIPE_API_URL] containing info
|
||||||
|
* about the latest NewPipe version
|
||||||
|
* and displays a notification about ana available update.
|
||||||
|
* <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.
|
||||||
|
|
|
@ -24,12 +24,12 @@ import android.widget.Toast;
|
||||||
import androidx.annotation.DrawableRes;
|
import androidx.annotation.DrawableRes;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.annotation.StringRes;
|
||||||
import androidx.appcompat.app.AlertDialog;
|
import androidx.appcompat.app.AlertDialog;
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
import androidx.appcompat.content.res.AppCompatResources;
|
import androidx.appcompat.content.res.AppCompatResources;
|
||||||
import androidx.core.app.NotificationCompat;
|
import androidx.core.app.NotificationCompat;
|
||||||
import androidx.core.app.ServiceCompat;
|
import androidx.core.app.ServiceCompat;
|
||||||
import androidx.core.widget.TextViewCompat;
|
|
||||||
import androidx.fragment.app.FragmentManager;
|
import androidx.fragment.app.FragmentManager;
|
||||||
import androidx.preference.PreferenceManager;
|
import androidx.preference.PreferenceManager;
|
||||||
|
|
||||||
|
@ -58,7 +58,6 @@ import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException;
|
||||||
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException;
|
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException;
|
||||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
|
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||||
import org.schabi.newpipe.extractor.stream.VideoStream;
|
|
||||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||||
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||||
import org.schabi.newpipe.player.MainPlayer;
|
import org.schabi.newpipe.player.MainPlayer;
|
||||||
|
@ -71,7 +70,7 @@ import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
||||||
import org.schabi.newpipe.util.Constants;
|
import org.schabi.newpipe.util.Constants;
|
||||||
import org.schabi.newpipe.util.DeviceUtils;
|
import org.schabi.newpipe.util.DeviceUtils;
|
||||||
import org.schabi.newpipe.util.ExtractorHelper;
|
import org.schabi.newpipe.util.ExtractorHelper;
|
||||||
import org.schabi.newpipe.util.ListHelper;
|
import org.schabi.newpipe.util.Localization;
|
||||||
import org.schabi.newpipe.util.NavigationHelper;
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
import org.schabi.newpipe.util.PermissionHelper;
|
import org.schabi.newpipe.util.PermissionHelper;
|
||||||
import org.schabi.newpipe.util.ThemeHelper;
|
import org.schabi.newpipe.util.ThemeHelper;
|
||||||
|
@ -127,8 +126,10 @@ public class RouterActivity extends AppCompatActivity {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ThemeHelper.setDayNightMode(this);
|
||||||
setTheme(ThemeHelper.isLightThemeSelected(this)
|
setTheme(ThemeHelper.isLightThemeSelected(this)
|
||||||
? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark);
|
? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark);
|
||||||
|
Localization.assureCorrectAppLanguage(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -257,80 +258,122 @@ public class RouterActivity extends AppCompatActivity {
|
||||||
protected void onSuccess() {
|
protected void onSuccess() {
|
||||||
final SharedPreferences preferences = PreferenceManager
|
final SharedPreferences preferences = PreferenceManager
|
||||||
.getDefaultSharedPreferences(this);
|
.getDefaultSharedPreferences(this);
|
||||||
final String selectedChoiceKey = preferences
|
|
||||||
.getString(getString(R.string.preferred_open_action_key),
|
|
||||||
getString(R.string.preferred_open_action_default));
|
|
||||||
|
|
||||||
final String showInfoKey = getString(R.string.show_info_key);
|
final ChoiceAvailabilityChecker choiceChecker = new ChoiceAvailabilityChecker(
|
||||||
final String videoPlayerKey = getString(R.string.video_player_key);
|
getChoicesForService(currentService, currentLinkType),
|
||||||
final String backgroundPlayerKey = getString(R.string.background_player_key);
|
preferences.getString(getString(R.string.preferred_open_action_key),
|
||||||
final String popupPlayerKey = getString(R.string.popup_player_key);
|
getString(R.string.preferred_open_action_default)));
|
||||||
final String downloadKey = getString(R.string.download_key);
|
|
||||||
final String alwaysAskKey = getString(R.string.always_ask_open_action_key);
|
|
||||||
|
|
||||||
if (selectedChoiceKey.equals(alwaysAskKey)) {
|
// Check for non-player related choices
|
||||||
final List<AdapterChoiceItem> choices
|
if (choiceChecker.isAvailableAndSelected(
|
||||||
= getChoicesForService(currentService, currentLinkType);
|
R.string.show_info_key,
|
||||||
|
R.string.download_key,
|
||||||
switch (choices.size()) {
|
R.string.add_to_playlist_key)) {
|
||||||
case 1:
|
handleChoice(choiceChecker.getSelectedChoiceKey());
|
||||||
handleChoice(choices.get(0).key);
|
return;
|
||||||
break;
|
|
||||||
case 0:
|
|
||||||
handleChoice(showInfoKey);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
showDialog(choices);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
} else if (selectedChoiceKey.equals(showInfoKey)) {
|
// Check if the choice is player related
|
||||||
handleChoice(showInfoKey);
|
if (choiceChecker.isAvailableAndSelected(
|
||||||
} else if (selectedChoiceKey.equals(downloadKey)) {
|
R.string.video_player_key,
|
||||||
handleChoice(downloadKey);
|
R.string.background_player_key,
|
||||||
} else {
|
R.string.popup_player_key)) {
|
||||||
|
|
||||||
|
final String selectedChoice = choiceChecker.getSelectedChoiceKey();
|
||||||
|
|
||||||
final boolean isExtVideoEnabled = preferences.getBoolean(
|
final boolean isExtVideoEnabled = preferences.getBoolean(
|
||||||
getString(R.string.use_external_video_player_key), false);
|
getString(R.string.use_external_video_player_key), false);
|
||||||
final boolean isExtAudioEnabled = preferences.getBoolean(
|
final boolean isExtAudioEnabled = preferences.getBoolean(
|
||||||
getString(R.string.use_external_audio_player_key), false);
|
getString(R.string.use_external_audio_player_key), false);
|
||||||
final boolean isVideoPlayerSelected = selectedChoiceKey.equals(videoPlayerKey)
|
final boolean isVideoPlayerSelected =
|
||||||
|| selectedChoiceKey.equals(popupPlayerKey);
|
selectedChoice.equals(getString(R.string.video_player_key))
|
||||||
final boolean isAudioPlayerSelected = selectedChoiceKey.equals(backgroundPlayerKey);
|
|| selectedChoice.equals(getString(R.string.popup_player_key));
|
||||||
|
final boolean isAudioPlayerSelected =
|
||||||
|
selectedChoice.equals(getString(R.string.background_player_key));
|
||||||
|
|
||||||
if (currentLinkType != LinkType.STREAM) {
|
if (currentLinkType != LinkType.STREAM
|
||||||
if (isExtAudioEnabled && isAudioPlayerSelected
|
&& ((isExtAudioEnabled && isAudioPlayerSelected)
|
||||||
|| isExtVideoEnabled && isVideoPlayerSelected) {
|
|| (isExtVideoEnabled && isVideoPlayerSelected))
|
||||||
|
) {
|
||||||
Toast.makeText(this, R.string.external_player_unsupported_link_type,
|
Toast.makeText(this, R.string.external_player_unsupported_link_type,
|
||||||
Toast.LENGTH_LONG).show();
|
Toast.LENGTH_LONG).show();
|
||||||
handleChoice(showInfoKey);
|
handleChoice(getString(R.string.show_info_key));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
final List<StreamingService.ServiceInfo.MediaCapability> capabilities
|
final List<StreamingService.ServiceInfo.MediaCapability> capabilities =
|
||||||
= currentService.getServiceInfo().getMediaCapabilities();
|
currentService.getServiceInfo().getMediaCapabilities();
|
||||||
|
|
||||||
boolean serviceSupportsChoice = false;
|
// Check if the service supports the choice
|
||||||
if (isVideoPlayerSelected) {
|
if ((isVideoPlayerSelected && capabilities.contains(VIDEO))
|
||||||
serviceSupportsChoice = capabilities.contains(VIDEO);
|
|| (isAudioPlayerSelected && capabilities.contains(AUDIO))) {
|
||||||
} else if (selectedChoiceKey.equals(backgroundPlayerKey)) {
|
handleChoice(selectedChoice);
|
||||||
serviceSupportsChoice = capabilities.contains(AUDIO);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (serviceSupportsChoice) {
|
|
||||||
handleChoice(selectedChoiceKey);
|
|
||||||
} else {
|
} else {
|
||||||
handleChoice(showInfoKey);
|
handleChoice(getString(R.string.show_info_key));
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default / Ask always
|
||||||
|
final List<AdapterChoiceItem> availableChoices = choiceChecker.getAvailableChoices();
|
||||||
|
switch (availableChoices.size()) {
|
||||||
|
case 1:
|
||||||
|
handleChoice(availableChoices.get(0).key);
|
||||||
|
break;
|
||||||
|
case 0:
|
||||||
|
handleChoice(getString(R.string.show_info_key));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
showDialog(availableChoices);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a helper class for checking if the choices are available and/or selected.
|
||||||
|
*/
|
||||||
|
class ChoiceAvailabilityChecker {
|
||||||
|
private final List<AdapterChoiceItem> availableChoices;
|
||||||
|
private final String selectedChoiceKey;
|
||||||
|
|
||||||
|
ChoiceAvailabilityChecker(
|
||||||
|
@NonNull final List<AdapterChoiceItem> availableChoices,
|
||||||
|
@NonNull final String selectedChoiceKey) {
|
||||||
|
this.availableChoices = availableChoices;
|
||||||
|
this.selectedChoiceKey = selectedChoiceKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AdapterChoiceItem> getAvailableChoices() {
|
||||||
|
return availableChoices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSelectedChoiceKey() {
|
||||||
|
return selectedChoiceKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAvailableAndSelected(@StringRes final int... wantedKeys) {
|
||||||
|
return Arrays.stream(wantedKeys).anyMatch(this::isAvailableAndSelected);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAvailableAndSelected(@StringRes final int wantedKey) {
|
||||||
|
final String wanted = getString(wantedKey);
|
||||||
|
// Check if the wanted option is selected
|
||||||
|
if (!selectedChoiceKey.equals(wanted)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Check if it's available
|
||||||
|
return availableChoices.stream().anyMatch(item -> wanted.equals(item.key));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showDialog(final List<AdapterChoiceItem> choices) {
|
private void showDialog(final List<AdapterChoiceItem> choices) {
|
||||||
final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
|
final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
|
||||||
final Context themeWrapperContext = getThemeWrapperContext();
|
|
||||||
|
|
||||||
final LayoutInflater inflater = LayoutInflater.from(themeWrapperContext);
|
final Context themeWrapperContext = getThemeWrapperContext();
|
||||||
final RadioGroup radioGroup = SingleChoiceDialogViewBinding.inflate(getLayoutInflater())
|
final LayoutInflater layoutInflater = LayoutInflater.from(themeWrapperContext);
|
||||||
.list;
|
|
||||||
|
final SingleChoiceDialogViewBinding binding =
|
||||||
|
SingleChoiceDialogViewBinding.inflate(layoutInflater);
|
||||||
|
final RadioGroup radioGroup = binding.list;
|
||||||
|
|
||||||
final DialogInterface.OnClickListener dialogButtonsClickListener = (dialog, which) -> {
|
final DialogInterface.OnClickListener dialogButtonsClickListener = (dialog, which) -> {
|
||||||
final int indexOfChild = radioGroup.indexOfChild(
|
final int indexOfChild = radioGroup.indexOfChild(
|
||||||
|
@ -349,21 +392,19 @@ public class RouterActivity extends AppCompatActivity {
|
||||||
|
|
||||||
alertDialogChoice = new AlertDialog.Builder(themeWrapperContext)
|
alertDialogChoice = new AlertDialog.Builder(themeWrapperContext)
|
||||||
.setTitle(R.string.preferred_open_action_share_menu_title)
|
.setTitle(R.string.preferred_open_action_share_menu_title)
|
||||||
.setView(radioGroup)
|
.setView(binding.getRoot())
|
||||||
.setCancelable(true)
|
.setCancelable(true)
|
||||||
.setNegativeButton(R.string.just_once, dialogButtonsClickListener)
|
.setNegativeButton(R.string.just_once, dialogButtonsClickListener)
|
||||||
.setPositiveButton(R.string.always, dialogButtonsClickListener)
|
.setPositiveButton(R.string.always, dialogButtonsClickListener)
|
||||||
.setOnDismissListener((dialog) -> {
|
.setOnDismissListener(dialog -> {
|
||||||
if (!selectionIsDownload && !selectionIsAddToPlaylist) {
|
if (!selectionIsDownload && !selectionIsAddToPlaylist) {
|
||||||
finish();
|
finish();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.create();
|
.create();
|
||||||
|
|
||||||
//noinspection CodeBlock2Expr
|
alertDialogChoice.setOnShowListener(dialog -> setDialogButtonsState(
|
||||||
alertDialogChoice.setOnShowListener(dialog -> {
|
alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1));
|
||||||
setDialogButtonsState(alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1);
|
|
||||||
});
|
|
||||||
|
|
||||||
radioGroup.setOnCheckedChangeListener((group, checkedId) ->
|
radioGroup.setOnCheckedChangeListener((group, checkedId) ->
|
||||||
setDialogButtonsState(alertDialogChoice, true));
|
setDialogButtonsState(alertDialogChoice, true));
|
||||||
|
@ -383,9 +424,10 @@ public class RouterActivity extends AppCompatActivity {
|
||||||
|
|
||||||
int id = 12345;
|
int id = 12345;
|
||||||
for (final AdapterChoiceItem item : choices) {
|
for (final AdapterChoiceItem item : choices) {
|
||||||
final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater).getRoot();
|
final RadioButton radioButton = ListRadioIconItemBinding.inflate(layoutInflater)
|
||||||
|
.getRoot();
|
||||||
radioButton.setText(item.description);
|
radioButton.setText(item.description);
|
||||||
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(radioButton,
|
radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||||
AppCompatResources.getDrawable(themeWrapperContext, item.icon),
|
AppCompatResources.getDrawable(themeWrapperContext, item.icon),
|
||||||
null, null, null);
|
null, null, null);
|
||||||
radioButton.setChecked(false);
|
radioButton.setChecked(false);
|
||||||
|
@ -425,12 +467,47 @@ public class RouterActivity extends AppCompatActivity {
|
||||||
|
|
||||||
private List<AdapterChoiceItem> getChoicesForService(final StreamingService service,
|
private List<AdapterChoiceItem> getChoicesForService(final StreamingService service,
|
||||||
final LinkType linkType) {
|
final LinkType linkType) {
|
||||||
final Context context = getThemeWrapperContext();
|
final AdapterChoiceItem showInfo = new AdapterChoiceItem(
|
||||||
|
getString(R.string.show_info_key), getString(R.string.show_info),
|
||||||
|
R.drawable.ic_info_outline);
|
||||||
|
final AdapterChoiceItem videoPlayer = new AdapterChoiceItem(
|
||||||
|
getString(R.string.video_player_key), getString(R.string.video_player),
|
||||||
|
R.drawable.ic_play_arrow);
|
||||||
|
final AdapterChoiceItem backgroundPlayer = new AdapterChoiceItem(
|
||||||
|
getString(R.string.background_player_key), getString(R.string.background_player),
|
||||||
|
R.drawable.ic_headset);
|
||||||
|
final AdapterChoiceItem popupPlayer = new AdapterChoiceItem(
|
||||||
|
getString(R.string.popup_player_key), getString(R.string.popup_player),
|
||||||
|
R.drawable.ic_picture_in_picture);
|
||||||
|
|
||||||
final List<AdapterChoiceItem> returnList = new ArrayList<>();
|
final List<AdapterChoiceItem> returnedItems = new ArrayList<>();
|
||||||
final List<StreamingService.ServiceInfo.MediaCapability> capabilities
|
returnedItems.add(showInfo); // Always present
|
||||||
= service.getServiceInfo().getMediaCapabilities();
|
|
||||||
|
|
||||||
|
final List<StreamingService.ServiceInfo.MediaCapability> capabilities =
|
||||||
|
service.getServiceInfo().getMediaCapabilities();
|
||||||
|
|
||||||
|
if (linkType == LinkType.STREAM) {
|
||||||
|
if (capabilities.contains(VIDEO)) {
|
||||||
|
returnedItems.add(videoPlayer);
|
||||||
|
returnedItems.add(popupPlayer);
|
||||||
|
}
|
||||||
|
if (capabilities.contains(AUDIO)) {
|
||||||
|
returnedItems.add(backgroundPlayer);
|
||||||
|
}
|
||||||
|
// download is redundant for linkType CHANNEL AND PLAYLIST (till playlist downloading is
|
||||||
|
// not supported )
|
||||||
|
returnedItems.add(new AdapterChoiceItem(getString(R.string.download_key),
|
||||||
|
getString(R.string.download),
|
||||||
|
R.drawable.ic_file_download));
|
||||||
|
|
||||||
|
// Add to playlist is not necessary for CHANNEL and PLAYLIST linkType since those can
|
||||||
|
// not be added to a playlist
|
||||||
|
returnedItems.add(new AdapterChoiceItem(getString(R.string.add_to_playlist_key),
|
||||||
|
getString(R.string.add_to_playlist),
|
||||||
|
R.drawable.ic_add));
|
||||||
|
} else {
|
||||||
|
// LinkType.NONE is never present because it's filtered out before
|
||||||
|
// channels and playlist can be played as they contain a list of videos
|
||||||
final SharedPreferences preferences = PreferenceManager
|
final SharedPreferences preferences = PreferenceManager
|
||||||
.getDefaultSharedPreferences(this);
|
.getDefaultSharedPreferences(this);
|
||||||
final boolean isExtVideoEnabled = preferences.getBoolean(
|
final boolean isExtVideoEnabled = preferences.getBoolean(
|
||||||
|
@ -438,74 +515,16 @@ public class RouterActivity extends AppCompatActivity {
|
||||||
final boolean isExtAudioEnabled = preferences.getBoolean(
|
final boolean isExtAudioEnabled = preferences.getBoolean(
|
||||||
getString(R.string.use_external_audio_player_key), false);
|
getString(R.string.use_external_audio_player_key), false);
|
||||||
|
|
||||||
final AdapterChoiceItem videoPlayer = new AdapterChoiceItem(
|
|
||||||
getString(R.string.video_player_key), getString(R.string.video_player),
|
|
||||||
R.drawable.ic_play_arrow);
|
|
||||||
final AdapterChoiceItem showInfo = new AdapterChoiceItem(
|
|
||||||
getString(R.string.show_info_key), getString(R.string.show_info),
|
|
||||||
R.drawable.ic_info_outline);
|
|
||||||
final AdapterChoiceItem popupPlayer = new AdapterChoiceItem(
|
|
||||||
getString(R.string.popup_player_key), getString(R.string.popup_player),
|
|
||||||
R.drawable.ic_picture_in_picture);
|
|
||||||
final AdapterChoiceItem backgroundPlayer = new AdapterChoiceItem(
|
|
||||||
getString(R.string.background_player_key), getString(R.string.background_player),
|
|
||||||
R.drawable.ic_headset);
|
|
||||||
final AdapterChoiceItem addToPlaylist = new AdapterChoiceItem(
|
|
||||||
getString(R.string.add_to_playlist_key), getString(R.string.add_to_playlist),
|
|
||||||
R.drawable.ic_add);
|
|
||||||
|
|
||||||
|
|
||||||
if (linkType == LinkType.STREAM) {
|
|
||||||
if (isExtVideoEnabled) {
|
|
||||||
// show both "show info" and "video player", they are two different activities
|
|
||||||
returnList.add(showInfo);
|
|
||||||
returnList.add(videoPlayer);
|
|
||||||
} else {
|
|
||||||
final MainPlayer.PlayerType playerType = PlayerHolder.getInstance().getType();
|
|
||||||
if (capabilities.contains(VIDEO)
|
|
||||||
&& PlayerHelper.isAutoplayAllowedByUser(context)
|
|
||||||
&& playerType == null || playerType == MainPlayer.PlayerType.VIDEO) {
|
|
||||||
// show only "video player" since the details activity will be opened and the
|
|
||||||
// video will be auto played there. Since "show info" would do the exact same
|
|
||||||
// thing, use that as a key to let VideoDetailFragment load the stream instead
|
|
||||||
// of using FetcherService (see comment in handleChoice())
|
|
||||||
returnList.add(new AdapterChoiceItem(
|
|
||||||
showInfo.key, videoPlayer.description, videoPlayer.icon));
|
|
||||||
} else {
|
|
||||||
// show only "show info" if video player is not applicable, auto play is
|
|
||||||
// disabled or a video is playing in a player different than the main one
|
|
||||||
returnList.add(showInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (capabilities.contains(VIDEO)) {
|
|
||||||
returnList.add(popupPlayer);
|
|
||||||
}
|
|
||||||
if (capabilities.contains(AUDIO)) {
|
|
||||||
returnList.add(backgroundPlayer);
|
|
||||||
}
|
|
||||||
// download is redundant for linkType CHANNEL AND PLAYLIST (till playlist downloading is
|
|
||||||
// not supported )
|
|
||||||
returnList.add(new AdapterChoiceItem(getString(R.string.download_key),
|
|
||||||
getString(R.string.download),
|
|
||||||
R.drawable.ic_file_download));
|
|
||||||
|
|
||||||
// Add to playlist is not necessary for CHANNEL and PLAYLIST linkType since those can
|
|
||||||
// not be added to a playlist
|
|
||||||
returnList.add(addToPlaylist);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
returnList.add(showInfo);
|
|
||||||
if (capabilities.contains(VIDEO) && !isExtVideoEnabled) {
|
if (capabilities.contains(VIDEO) && !isExtVideoEnabled) {
|
||||||
returnList.add(videoPlayer);
|
returnedItems.add(videoPlayer);
|
||||||
returnList.add(popupPlayer);
|
returnedItems.add(popupPlayer);
|
||||||
}
|
}
|
||||||
if (capabilities.contains(AUDIO) && !isExtAudioEnabled) {
|
if (capabilities.contains(AUDIO) && !isExtAudioEnabled) {
|
||||||
returnList.add(backgroundPlayer);
|
returnedItems.add(backgroundPlayer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return returnList;
|
return returnedItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Context getThemeWrapperContext() {
|
private Context getThemeWrapperContext() {
|
||||||
|
@ -567,7 +586,8 @@ public class RouterActivity extends AppCompatActivity {
|
||||||
|
|
||||||
// stop and bypass FetcherService if InfoScreen was selected since
|
// stop and bypass FetcherService if InfoScreen was selected since
|
||||||
// StreamDetailFragment can fetch data itself
|
// StreamDetailFragment can fetch data itself
|
||||||
if (selectedChoiceKey.equals(getString(R.string.show_info_key))) {
|
if (selectedChoiceKey.equals(getString(R.string.show_info_key))
|
||||||
|
|| canHandleChoiceLikeShowInfo(selectedChoiceKey)) {
|
||||||
disposables.add(Observable
|
disposables.add(Observable
|
||||||
.fromCallable(() -> NavigationHelper.getIntentByLink(this, currentUrl))
|
.fromCallable(() -> NavigationHelper.getIntentByLink(this, currentUrl))
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
|
@ -590,6 +610,30 @@ public class RouterActivity extends AppCompatActivity {
|
||||||
finish();
|
finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean canHandleChoiceLikeShowInfo(final String selectedChoiceKey) {
|
||||||
|
if (!selectedChoiceKey.equals(getString(R.string.video_player_key))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// "video player" can be handled like "show info" (because VideoDetailFragment can load
|
||||||
|
// the stream instead of FetcherService) when...
|
||||||
|
|
||||||
|
// ...Autoplay is enabled
|
||||||
|
if (!PlayerHelper.isAutoplayAllowedByUser(getThemeWrapperContext())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final boolean isExtVideoEnabled = PreferenceManager.getDefaultSharedPreferences(this)
|
||||||
|
.getBoolean(getString(R.string.use_external_video_player_key), false);
|
||||||
|
// ...it's not done via an external player
|
||||||
|
if (isExtVideoEnabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...the player is not running or in normal Video-mode/type
|
||||||
|
final MainPlayer.PlayerType playerType = PlayerHolder.getInstance().getType();
|
||||||
|
return playerType == null || playerType == MainPlayer.PlayerType.VIDEO;
|
||||||
|
}
|
||||||
|
|
||||||
private void openAddToPlaylistDialog() {
|
private void openAddToPlaylistDialog() {
|
||||||
// Getting the stream info usually takes a moment
|
// Getting the stream info usually takes a moment
|
||||||
// Notifying the user here to ensure that no confusion arises
|
// Notifying the user here to ensure that no confusion arises
|
||||||
|
@ -631,22 +675,13 @@ public class RouterActivity extends AppCompatActivity {
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(result -> {
|
.subscribe(result -> {
|
||||||
final List<VideoStream> sortedVideoStreams = ListHelper
|
final DownloadDialog downloadDialog = new DownloadDialog(this, result);
|
||||||
.getSortedStreamVideosList(this, result.getVideoStreams(),
|
downloadDialog.setOnDismissListener(dialog -> finish());
|
||||||
result.getVideoOnlyStreams(), false);
|
|
||||||
final int selectedVideoStreamIndex = ListHelper
|
|
||||||
.getDefaultResolutionIndex(this, sortedVideoStreams);
|
|
||||||
|
|
||||||
final FragmentManager fm = getSupportFragmentManager();
|
final FragmentManager fm = getSupportFragmentManager();
|
||||||
final DownloadDialog downloadDialog = DownloadDialog.newInstance(result);
|
|
||||||
downloadDialog.setVideoStreams(sortedVideoStreams);
|
|
||||||
downloadDialog.setAudioStreams(result.getAudioStreams());
|
|
||||||
downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex);
|
|
||||||
downloadDialog.setOnDismissListener(dialog -> finish());
|
|
||||||
downloadDialog.show(fm, "downloadDialog");
|
downloadDialog.show(fm, "downloadDialog");
|
||||||
fm.executePendingTransactions();
|
fm.executePendingTransactions();
|
||||||
}, throwable ->
|
}, throwable -> showUnsupportedUrlDialog(currentUrl)));
|
||||||
showUnsupportedUrlDialog(currentUrl)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -672,8 +707,8 @@ public class RouterActivity extends AppCompatActivity {
|
||||||
final int icon;
|
final int icon;
|
||||||
|
|
||||||
AdapterChoiceItem(final String key, final String description, final int icon) {
|
AdapterChoiceItem(final String key, final String description, final int icon) {
|
||||||
this.description = description;
|
|
||||||
this.key = key;
|
this.key = key;
|
||||||
|
this.description = description;
|
||||||
this.icon = icon;
|
this.icon = icon;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
setView(webView)
|
||||||
Localization.assureCorrectAppLanguage(context)
|
Localization.assureCorrectAppLanguage(context)
|
||||||
alert.setNegativeButton(
|
block(this)
|
||||||
context.getString(R.string.ok)
|
show()
|
||||||
) { dialog, _ -> dialog.dismiss() }
|
}
|
||||||
alert.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@JvmStatic
|
|
||||||
fun showLicense(context: Context?, component: SoftwareComponent): Disposable {
|
|
||||||
return if (context == null) {
|
|
||||||
Disposable.empty()
|
|
||||||
} else {
|
|
||||||
Observable.fromCallable { getFormattedLicense(context, component.license) }
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe { formattedLicense: String ->
|
|
||||||
val webViewData = Base64.encodeToString(
|
|
||||||
formattedLicense
|
|
||||||
.toByteArray(StandardCharsets.UTF_8),
|
|
||||||
Base64.NO_PADDING
|
|
||||||
)
|
|
||||||
val webView = WebView(context)
|
|
||||||
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
|
|
||||||
val alert = AlertDialog.Builder(context)
|
|
||||||
alert.setTitle(component.license.name)
|
|
||||||
alert.setView(webView)
|
|
||||||
Localization.assureCorrectAppLanguage(context)
|
|
||||||
alert.setPositiveButton(
|
|
||||||
R.string.dismiss
|
|
||||||
) { dialog, _ -> dialog.dismiss() }
|
|
||||||
alert.setNeutralButton(R.string.open_website_license) { _, _ ->
|
|
||||||
ShareUtils.openUrlInBrowser(context, component.link)
|
|
||||||
}
|
|
||||||
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
|
||||||
|
|
|
@ -4,7 +4,6 @@ import androidx.annotation.NonNull;
|
||||||
import androidx.room.ColumnInfo;
|
import androidx.room.ColumnInfo;
|
||||||
import androidx.room.Entity;
|
import androidx.room.Entity;
|
||||||
import androidx.room.ForeignKey;
|
import androidx.room.ForeignKey;
|
||||||
import androidx.room.Ignore;
|
|
||||||
import androidx.room.Index;
|
import androidx.room.Index;
|
||||||
|
|
||||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||||
|
@ -42,18 +41,19 @@ public class StreamHistoryEntity {
|
||||||
@ColumnInfo(name = STREAM_REPEAT_COUNT)
|
@ColumnInfo(name = STREAM_REPEAT_COUNT)
|
||||||
private long repeatCount;
|
private long repeatCount;
|
||||||
|
|
||||||
public StreamHistoryEntity(final long streamUid, @NonNull final OffsetDateTime accessDate,
|
/**
|
||||||
|
* @param streamUid the stream id this history item will refer to
|
||||||
|
* @param accessDate the last time the stream was accessed
|
||||||
|
* @param repeatCount the total number of views this stream received
|
||||||
|
*/
|
||||||
|
public StreamHistoryEntity(final long streamUid,
|
||||||
|
@NonNull final OffsetDateTime accessDate,
|
||||||
final long repeatCount) {
|
final long repeatCount) {
|
||||||
this.streamUid = streamUid;
|
this.streamUid = streamUid;
|
||||||
this.accessDate = accessDate;
|
this.accessDate = accessDate;
|
||||||
this.repeatCount = repeatCount;
|
this.repeatCount = repeatCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Ignore
|
|
||||||
public StreamHistoryEntity(final long streamUid, @NonNull final OffsetDateTime accessDate) {
|
|
||||||
this(streamUid, accessDate, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
public long getStreamUid() {
|
public long getStreamUid() {
|
||||||
return streamUid;
|
return streamUid;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -12,8 +12,7 @@ import org.schabi.newpipe.database.BasicDAO
|
||||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||||
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
|
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
|
||||||
import org.schabi.newpipe.extractor.stream.StreamType
|
import org.schabi.newpipe.extractor.stream.StreamType
|
||||||
import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM
|
import org.schabi.newpipe.util.StreamTypeUtil
|
||||||
import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM
|
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
|
@ -39,6 +38,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
|
||||||
|
@ -88,8 +90,7 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
|
||||||
?: throw IllegalStateException("Stream cannot be null just after insertion.")
|
?: throw IllegalStateException("Stream cannot be null just after insertion.")
|
||||||
newerStream.uid = existentMinimalStream.uid
|
newerStream.uid = existentMinimalStream.uid
|
||||||
|
|
||||||
val isNewerStreamLive = newerStream.streamType == AUDIO_LIVE_STREAM || newerStream.streamType == LIVE_STREAM
|
if (!StreamTypeUtil.isLiveStream(newerStream.streamType)) {
|
||||||
if (!isNewerStreamLive) {
|
|
||||||
|
|
||||||
// Use the existent upload date if the newer stream does not have a better precision
|
// Use the existent upload date if the newer stream does not have a better precision
|
||||||
// (i.e. is an approximation). This is done to prevent unnecessary changes.
|
// (i.e. is an approximation). This is done to prevent unnecessary changes.
|
||||||
|
|
|
@ -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;
|
||||||
|
@ -68,9 +69,9 @@ import org.schabi.newpipe.util.VideoSegment;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
import icepick.Icepick;
|
import icepick.Icepick;
|
||||||
import icepick.State;
|
import icepick.State;
|
||||||
|
@ -82,6 +83,8 @@ import us.shandian.giga.service.DownloadManagerService;
|
||||||
import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder;
|
import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder;
|
||||||
import us.shandian.giga.service.MissionState;
|
import us.shandian.giga.service.MissionState;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.extractor.stream.DeliveryMethod.PROGRESSIVE_HTTP;
|
||||||
|
import static org.schabi.newpipe.util.ListHelper.getStreamsOfSpecifiedDelivery;
|
||||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||||
|
|
||||||
public class DownloadDialog extends DialogFragment
|
public class DownloadDialog extends DialogFragment
|
||||||
|
@ -92,17 +95,17 @@ public class DownloadDialog extends DialogFragment
|
||||||
@State
|
@State
|
||||||
StreamInfo currentInfo;
|
StreamInfo currentInfo;
|
||||||
@State
|
@State
|
||||||
StreamSizeWrapper<AudioStream> wrappedAudioStreams = StreamSizeWrapper.empty();
|
StreamSizeWrapper<AudioStream> wrappedAudioStreams;
|
||||||
@State
|
@State
|
||||||
StreamSizeWrapper<VideoStream> wrappedVideoStreams = StreamSizeWrapper.empty();
|
StreamSizeWrapper<VideoStream> wrappedVideoStreams;
|
||||||
@State
|
@State
|
||||||
StreamSizeWrapper<SubtitlesStream> wrappedSubtitleStreams = StreamSizeWrapper.empty();
|
StreamSizeWrapper<SubtitlesStream> wrappedSubtitleStreams;
|
||||||
@State
|
@State
|
||||||
int selectedVideoIndex = 0;
|
int selectedVideoIndex; // set in the constructor
|
||||||
@State
|
@State
|
||||||
int selectedAudioIndex = 0;
|
int selectedAudioIndex = 0; // default to the first item
|
||||||
@State
|
@State
|
||||||
int selectedSubtitleIndex = 0;
|
int selectedSubtitleIndex = 0; // default to the first item
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
private OnDismissListener onDismissListener = null;
|
private OnDismissListener onDismissListener = null;
|
||||||
|
@ -145,81 +148,47 @@ public class DownloadDialog extends DialogFragment
|
||||||
// Instance creation
|
// Instance creation
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
public static DownloadDialog newInstance(final StreamInfo info) {
|
/**
|
||||||
final DownloadDialog dialog = new DownloadDialog();
|
* Create a new download dialog with the video, audio and subtitle streams from the provided
|
||||||
dialog.setInfo(info);
|
* stream info. Video streams and video-only streams will be put into a single list menu,
|
||||||
return dialog;
|
* sorted according to their resolution and the default video resolution will be selected.
|
||||||
}
|
*
|
||||||
|
* @param context the context to use just to obtain preferences and strings (will not be stored)
|
||||||
public static DownloadDialog newInstance(final Context context, final StreamInfo info) {
|
* @param info the info from which to obtain downloadable streams and other info (e.g. title)
|
||||||
final ArrayList<VideoStream> streamsList = new ArrayList<>(ListHelper
|
*/
|
||||||
.getSortedStreamVideosList(context, info.getVideoStreams(),
|
public DownloadDialog(final Context context, @NonNull final StreamInfo info) {
|
||||||
info.getVideoOnlyStreams(), false));
|
|
||||||
final int selectedStreamIndex = ListHelper.getDefaultResolutionIndex(context, streamsList);
|
|
||||||
|
|
||||||
final DownloadDialog instance = newInstance(info);
|
|
||||||
instance.setVideoStreams(streamsList);
|
|
||||||
instance.setSelectedVideoStream(selectedStreamIndex);
|
|
||||||
instance.setAudioStreams(info.getAudioStreams());
|
|
||||||
instance.setSubtitleStreams(info.getSubtitles());
|
|
||||||
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Setters
|
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
private void setInfo(final StreamInfo info) {
|
|
||||||
this.currentInfo = info;
|
this.currentInfo = info;
|
||||||
}
|
|
||||||
|
|
||||||
public void setAudioStreams(final List<AudioStream> audioStreams) {
|
// TODO: Adapt this code when the downloader support other types of stream deliveries
|
||||||
setAudioStreams(new StreamSizeWrapper<>(audioStreams, getContext()));
|
final List<VideoStream> videoStreams = ListHelper.getSortedStreamVideosList(
|
||||||
}
|
context,
|
||||||
|
getStreamsOfSpecifiedDelivery(info.getVideoStreams(), PROGRESSIVE_HTTP),
|
||||||
|
getStreamsOfSpecifiedDelivery(info.getVideoOnlyStreams(), PROGRESSIVE_HTTP),
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
public void setAudioStreams(final StreamSizeWrapper<AudioStream> was) {
|
this.wrappedVideoStreams = new StreamSizeWrapper<>(videoStreams, context);
|
||||||
this.wrappedAudioStreams = was;
|
this.wrappedAudioStreams = new StreamSizeWrapper<>(
|
||||||
}
|
getStreamsOfSpecifiedDelivery(info.getAudioStreams(), PROGRESSIVE_HTTP), context);
|
||||||
|
this.wrappedSubtitleStreams = new StreamSizeWrapper<>(
|
||||||
|
getStreamsOfSpecifiedDelivery(info.getSubtitles(), PROGRESSIVE_HTTP), context);
|
||||||
|
|
||||||
public void setVideoStreams(final List<VideoStream> videoStreams) {
|
this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams);
|
||||||
setVideoStreams(new StreamSizeWrapper<>(videoStreams, getContext()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setVideoStreams(final StreamSizeWrapper<VideoStream> wvs) {
|
|
||||||
this.wrappedVideoStreams = wvs;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setSubtitleStreams(final List<SubtitlesStream> subtitleStreams) {
|
|
||||||
setSubtitleStreams(new StreamSizeWrapper<>(subtitleStreams, getContext()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setSubtitleStreams(
|
|
||||||
final StreamSizeWrapper<SubtitlesStream> wss) {
|
|
||||||
this.wrappedSubtitleStreams = wss;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setSelectedVideoStream(final int svi) {
|
|
||||||
this.selectedVideoIndex = svi;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setSelectedAudioStream(final int sai) {
|
|
||||||
this.selectedAudioIndex = sai;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setSelectedSubtitleStream(final int ssi) {
|
|
||||||
this.selectedSubtitleIndex = ssi;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setVideoSegments(final VideoSegment[] seg) {
|
public void setVideoSegments(final VideoSegment[] seg) {
|
||||||
this.segments = seg;
|
this.segments = seg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param onDismissListener the listener to call in {@link #onDismiss(DialogInterface)}
|
||||||
|
*/
|
||||||
public void setOnDismissListener(@Nullable final OnDismissListener onDismissListener) {
|
public void setOnDismissListener(@Nullable final OnDismissListener onDismissListener) {
|
||||||
this.onDismissListener = onDismissListener;
|
this.onDismissListener = onDismissListener;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
// Android lifecycle
|
// Android lifecycle
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
@ -255,11 +224,16 @@ public class DownloadDialog extends DialogFragment
|
||||||
.getAudioStreamFor(wrappedAudioStreams.getStreamsList(), videoStreams.get(i));
|
.getAudioStreamFor(wrappedAudioStreams.getStreamsList(), videoStreams.get(i));
|
||||||
|
|
||||||
if (audioStream != null) {
|
if (audioStream != null) {
|
||||||
secondaryStreams
|
secondaryStreams.append(i, new SecondaryStreamHelper<>(wrappedAudioStreams,
|
||||||
.append(i, new SecondaryStreamHelper<>(wrappedAudioStreams, audioStream));
|
audioStream));
|
||||||
} else if (DEBUG) {
|
} else if (DEBUG) {
|
||||||
|
final MediaFormat mediaFormat = videoStreams.get(i).getFormat();
|
||||||
|
if (mediaFormat != null) {
|
||||||
Log.w(TAG, "No audio stream candidates for video format "
|
Log.w(TAG, "No audio stream candidates for video format "
|
||||||
+ videoStreams.get(i).getFormat().name());
|
+ mediaFormat.name());
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "No audio stream candidates for unknown video format");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -294,7 +268,8 @@ public class DownloadDialog extends DialogFragment
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container,
|
public View onCreateView(@NonNull final LayoutInflater inflater,
|
||||||
|
final ViewGroup container,
|
||||||
final Bundle savedInstanceState) {
|
final Bundle savedInstanceState) {
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
Log.d(TAG, "onCreateView() called with: "
|
Log.d(TAG, "onCreateView() called with: "
|
||||||
|
@ -305,14 +280,15 @@ public class DownloadDialog extends DialogFragment
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) {
|
public void onViewCreated(@NonNull final View view,
|
||||||
|
@Nullable final Bundle savedInstanceState) {
|
||||||
super.onViewCreated(view, savedInstanceState);
|
super.onViewCreated(view, savedInstanceState);
|
||||||
dialogBinding = DownloadDialogBinding.bind(view);
|
dialogBinding = DownloadDialogBinding.bind(view);
|
||||||
|
|
||||||
dialogBinding.fileName.setText(FilenameUtils.createFilename(getContext(),
|
dialogBinding.fileName.setText(FilenameUtils.createFilename(getContext(),
|
||||||
currentInfo.getName()));
|
currentInfo.getName()));
|
||||||
selectedAudioIndex = ListHelper
|
selectedAudioIndex = ListHelper
|
||||||
.getDefaultAudioFormat(getContext(), currentInfo.getAudioStreams());
|
.getDefaultAudioFormat(getContext(), wrappedAudioStreams.getStreamsList());
|
||||||
|
|
||||||
selectedSubtitleIndex = getSubtitleIndexBy(subtitleStreamsAdapter.getAll());
|
selectedSubtitleIndex = getSubtitleIndexBy(subtitleStreamsAdapter.getAll());
|
||||||
|
|
||||||
|
@ -328,21 +304,16 @@ public class DownloadDialog extends DialogFragment
|
||||||
final int threads = prefs.getInt(getString(R.string.default_download_threads), 3);
|
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();
|
||||||
|
@ -481,7 +452,7 @@ public class DownloadDialog extends DialogFragment
|
||||||
result, getString(R.string.download_path_video_key), DownloadManager.TAG_VIDEO);
|
result, getString(R.string.download_path_video_key), DownloadManager.TAG_VIDEO);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requestDownloadSaveAsResult(final ActivityResult result) {
|
private void requestDownloadSaveAsResult(@NonNull final ActivityResult result) {
|
||||||
if (result.getResultCode() != Activity.RESULT_OK) {
|
if (result.getResultCode() != Activity.RESULT_OK) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -498,8 +469,8 @@ public class DownloadDialog extends DialogFragment
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final DocumentFile docFile
|
final DocumentFile docFile = DocumentFile.fromSingleUri(context,
|
||||||
= DocumentFile.fromSingleUri(context, result.getData().getData());
|
result.getData().getData());
|
||||||
if (docFile == null) {
|
if (docFile == null) {
|
||||||
showFailedDialog(R.string.general_error);
|
showFailedDialog(R.string.general_error);
|
||||||
return;
|
return;
|
||||||
|
@ -510,7 +481,7 @@ public class DownloadDialog extends DialogFragment
|
||||||
docFile.getType());
|
docFile.getType());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requestDownloadPickFolderResult(final ActivityResult result,
|
private void requestDownloadPickFolderResult(@NonNull final ActivityResult result,
|
||||||
final String key,
|
final String key,
|
||||||
final String tag) {
|
final String tag) {
|
||||||
if (result.getResultCode() != Activity.RESULT_OK) {
|
if (result.getResultCode() != Activity.RESULT_OK) {
|
||||||
|
@ -530,12 +501,11 @@ public class DownloadDialog extends DialogFragment
|
||||||
StoredDirectoryHelper.PERMISSION_FLAGS);
|
StoredDirectoryHelper.PERMISSION_FLAGS);
|
||||||
}
|
}
|
||||||
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
PreferenceManager.getDefaultSharedPreferences(context).edit().putString(key,
|
||||||
.putString(key, uri.toString()).apply();
|
uri.toString()).apply();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final StoredDirectoryHelper mainStorage
|
final StoredDirectoryHelper mainStorage = new StoredDirectoryHelper(context, uri, tag);
|
||||||
= new StoredDirectoryHelper(context, uri, tag);
|
|
||||||
checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp),
|
checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp),
|
||||||
filenameTmp, mimeTmp);
|
filenameTmp, mimeTmp);
|
||||||
} catch (final IOException e) {
|
} catch (final IOException e) {
|
||||||
|
@ -573,8 +543,10 @@ public class DownloadDialog extends DialogFragment
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onItemSelected(final AdapterView<?> parent, final View view,
|
public void onItemSelected(final AdapterView<?> parent,
|
||||||
final int position, final long id) {
|
final View view,
|
||||||
|
final int position,
|
||||||
|
final long id) {
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
Log.d(TAG, "onItemSelected() called with: "
|
Log.d(TAG, "onItemSelected() called with: "
|
||||||
+ "parent = [" + parent + "], view = [" + view + "], "
|
+ "parent = [" + parent + "], view = [" + view + "], "
|
||||||
|
@ -609,8 +581,10 @@ public class DownloadDialog extends DialogFragment
|
||||||
final boolean isAudioStreamsAvailable = audioStreamsAdapter.getCount() > 0;
|
final boolean isAudioStreamsAvailable = audioStreamsAdapter.getCount() > 0;
|
||||||
final boolean isSubtitleStreamsAvailable = subtitleStreamsAdapter.getCount() > 0;
|
final boolean isSubtitleStreamsAvailable = subtitleStreamsAdapter.getCount() > 0;
|
||||||
|
|
||||||
dialogBinding.audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE : View.GONE);
|
dialogBinding.audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE
|
||||||
dialogBinding.videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE : View.GONE);
|
: View.GONE);
|
||||||
|
dialogBinding.videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE
|
||||||
|
: View.GONE);
|
||||||
dialogBinding.subtitleButton.setVisibility(isSubtitleStreamsAvailable
|
dialogBinding.subtitleButton.setVisibility(isSubtitleStreamsAvailable
|
||||||
? View.VISIBLE : View.GONE);
|
? View.VISIBLE : View.GONE);
|
||||||
|
|
||||||
|
@ -652,7 +626,7 @@ public class DownloadDialog extends DialogFragment
|
||||||
dialogBinding.subtitleButton.setEnabled(enabled);
|
dialogBinding.subtitleButton.setEnabled(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
private int getSubtitleIndexBy(final List<SubtitlesStream> streams) {
|
private int getSubtitleIndexBy(@NonNull final List<SubtitlesStream> streams) {
|
||||||
final Localization preferredLocalization = NewPipe.getPreferredLocalization();
|
final Localization preferredLocalization = NewPipe.getPreferredLocalization();
|
||||||
|
|
||||||
int candidate = 0;
|
int candidate = 0;
|
||||||
|
@ -678,8 +652,10 @@ public class DownloadDialog extends DialogFragment
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
private String getNameEditText() {
|
private String getNameEditText() {
|
||||||
final String str = dialogBinding.fileName.getText().toString().trim();
|
final String str = Objects.requireNonNull(dialogBinding.fileName.getText()).toString()
|
||||||
|
.trim();
|
||||||
|
|
||||||
return FilenameUtils.createFilename(context, str.isEmpty() ? currentInfo.getName() : str);
|
return FilenameUtils.createFilename(context, str.isEmpty() ? currentInfo.getName() : str);
|
||||||
}
|
}
|
||||||
|
@ -695,12 +671,8 @@ public class DownloadDialog extends DialogFragment
|
||||||
}
|
}
|
||||||
|
|
||||||
private void launchDirectoryPicker(final ActivityResultLauncher<Intent> launcher) {
|
private void launchDirectoryPicker(final ActivityResultLauncher<Intent> launcher) {
|
||||||
NoFileManagerSafeGuard.launchSafe(
|
NoFileManagerSafeGuard.launchSafe(launcher, StoredDirectoryHelper.getPicker(context), TAG,
|
||||||
launcher,
|
context);
|
||||||
StoredDirectoryHelper.getPicker(context),
|
|
||||||
TAG,
|
|
||||||
context
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void prepareSelectedDownload() {
|
private void prepareSelectedDownload() {
|
||||||
|
@ -721,7 +693,7 @@ public class DownloadDialog extends DialogFragment
|
||||||
if (format == MediaFormat.WEBMA_OPUS) {
|
if (format == MediaFormat.WEBMA_OPUS) {
|
||||||
mimeTmp = "audio/ogg";
|
mimeTmp = "audio/ogg";
|
||||||
filenameTmp += "opus";
|
filenameTmp += "opus";
|
||||||
} else {
|
} else if (format != null) {
|
||||||
mimeTmp = format.mimeType;
|
mimeTmp = format.mimeType;
|
||||||
filenameTmp += format.suffix;
|
filenameTmp += format.suffix;
|
||||||
}
|
}
|
||||||
|
@ -730,22 +702,30 @@ public class DownloadDialog extends DialogFragment
|
||||||
selectedMediaType = getString(R.string.last_download_type_video_key);
|
selectedMediaType = getString(R.string.last_download_type_video_key);
|
||||||
mainStorage = mainStorageVideo;
|
mainStorage = mainStorageVideo;
|
||||||
format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat();
|
format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat();
|
||||||
|
if (format != null) {
|
||||||
mimeTmp = format.mimeType;
|
mimeTmp = format.mimeType;
|
||||||
filenameTmp += format.suffix;
|
filenameTmp += format.suffix;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case R.id.subtitle_button:
|
case R.id.subtitle_button:
|
||||||
selectedMediaType = getString(R.string.last_download_type_subtitle_key);
|
selectedMediaType = getString(R.string.last_download_type_subtitle_key);
|
||||||
mainStorage = mainStorageVideo; // subtitle & video files go together
|
mainStorage = mainStorageVideo; // subtitle & video files go together
|
||||||
format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat();
|
format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat();
|
||||||
|
if (format != null) {
|
||||||
mimeTmp = format.mimeType;
|
mimeTmp = format.mimeType;
|
||||||
filenameTmp += (format == MediaFormat.TTML ? MediaFormat.SRT : format).suffix;
|
}
|
||||||
|
|
||||||
|
if (format == MediaFormat.TTML) {
|
||||||
|
filenameTmp += MediaFormat.SRT.suffix;
|
||||||
|
} else if (format != null) {
|
||||||
|
filenameTmp += format.suffix;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new RuntimeException("No stream selected");
|
throw new RuntimeException("No stream selected");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!askForSavePath
|
if (!askForSavePath && (mainStorage == null
|
||||||
&& (mainStorage == null
|
|
||||||
|| mainStorage.isDirect() == NewPipeSettings.useStorageAccessFramework(context)
|
|| mainStorage.isDirect() == NewPipeSettings.useStorageAccessFramework(context)
|
||||||
|| mainStorage.isInvalidSafStorage())) {
|
|| mainStorage.isInvalidSafStorage())) {
|
||||||
// Pick new download folder if one of:
|
// Pick new download folder if one of:
|
||||||
|
@ -779,18 +759,16 @@ public class DownloadDialog extends DialogFragment
|
||||||
initialPath = Uri.parse(initialSavePath.getAbsolutePath());
|
initialPath = Uri.parse(initialSavePath.getAbsolutePath());
|
||||||
}
|
}
|
||||||
|
|
||||||
NoFileManagerSafeGuard.launchSafe(
|
NoFileManagerSafeGuard.launchSafe(requestDownloadSaveAsLauncher,
|
||||||
requestDownloadSaveAsLauncher,
|
StoredFileHelper.getNewPicker(context, filenameTmp, mimeTmp, initialPath), TAG,
|
||||||
StoredFileHelper.getNewPicker(context, filenameTmp, mimeTmp, initialPath),
|
context);
|
||||||
TAG,
|
|
||||||
context
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// check for existing file with the same name
|
// check for existing file with the same name
|
||||||
checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp, mimeTmp);
|
checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp,
|
||||||
|
mimeTmp);
|
||||||
|
|
||||||
// remember the last media type downloaded by the user
|
// remember the last media type downloaded by the user
|
||||||
prefs.edit().putString(getString(R.string.last_used_download_type), selectedMediaType)
|
prefs.edit().putString(getString(R.string.last_used_download_type), selectedMediaType)
|
||||||
|
@ -798,7 +776,8 @@ public class DownloadDialog extends DialogFragment
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkSelectedDownload(final StoredDirectoryHelper mainStorage,
|
private void checkSelectedDownload(final StoredDirectoryHelper mainStorage,
|
||||||
final Uri targetFile, final String filename,
|
final Uri targetFile,
|
||||||
|
final String filename,
|
||||||
final String mime) {
|
final String mime) {
|
||||||
StoredFileHelper storage;
|
StoredFileHelper storage;
|
||||||
|
|
||||||
|
@ -959,7 +938,7 @@ public class DownloadDialog extends DialogFragment
|
||||||
storage.truncate();
|
storage.truncate();
|
||||||
}
|
}
|
||||||
} catch (final IOException e) {
|
} catch (final IOException e) {
|
||||||
Log.e(TAG, "failed to truncate the file: " + storage.getUri().toString(), e);
|
Log.e(TAG, "Failed to truncate the file: " + storage.getUri().toString(), e);
|
||||||
showFailedDialog(R.string.overwrite_failed);
|
showFailedDialog(R.string.overwrite_failed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -1004,8 +983,8 @@ public class DownloadDialog extends DialogFragment
|
||||||
}
|
}
|
||||||
|
|
||||||
psArgs = null;
|
psArgs = null;
|
||||||
final long videoSize = wrappedVideoStreams
|
final long videoSize = wrappedVideoStreams.getSizeInBytes(
|
||||||
.getSizeInBytes((VideoStream) selectedStream);
|
(VideoStream) selectedStream);
|
||||||
|
|
||||||
// set nearLength, only, if both sizes are fetched or known. This probably
|
// set nearLength, only, if both sizes are fetched or known. This probably
|
||||||
// does not work on slow networks but is later updated in the downloader
|
// does not work on slow networks but is later updated in the downloader
|
||||||
|
@ -1033,14 +1012,19 @@ public class DownloadDialog extends DialogFragment
|
||||||
|
|
||||||
if (secondaryStream == null) {
|
if (secondaryStream == null) {
|
||||||
urls = new String[] {
|
urls = new String[] {
|
||||||
selectedStream.getUrl()
|
selectedStream.getContent()
|
||||||
};
|
};
|
||||||
recoveryInfo = new MissionRecoveryInfo[] {
|
recoveryInfo = new MissionRecoveryInfo[] {
|
||||||
new MissionRecoveryInfo(selectedStream)
|
new MissionRecoveryInfo(selectedStream)
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
if (secondaryStream.getDeliveryMethod() != PROGRESSIVE_HTTP) {
|
||||||
|
throw new IllegalArgumentException("Unsupported stream delivery format"
|
||||||
|
+ secondaryStream.getDeliveryMethod());
|
||||||
|
}
|
||||||
|
|
||||||
urls = new String[] {
|
urls = new String[] {
|
||||||
selectedStream.getUrl(), secondaryStream.getUrl()
|
selectedStream.getContent(), secondaryStream.getContent()
|
||||||
};
|
};
|
||||||
recoveryInfo = new MissionRecoveryInfo[] {new MissionRecoveryInfo(selectedStream),
|
recoveryInfo = new MissionRecoveryInfo[] {new MissionRecoveryInfo(selectedStream),
|
||||||
new MissionRecoveryInfo(secondaryStream)};
|
new MissionRecoveryInfo(secondaryStream)};
|
||||||
|
|
|
@ -7,13 +7,13 @@ import kotlinx.parcelize.IgnoredOnParcel
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.schabi.newpipe.R
|
import org.schabi.newpipe.R
|
||||||
import org.schabi.newpipe.extractor.Info
|
import org.schabi.newpipe.extractor.Info
|
||||||
import org.schabi.newpipe.extractor.NewPipe
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
|
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
|
||||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException
|
import org.schabi.newpipe.extractor.exceptions.ExtractionException
|
||||||
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor.DeobfuscateException
|
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor.DeobfuscateException
|
||||||
import org.schabi.newpipe.ktx.isNetworkRelated
|
import org.schabi.newpipe.ktx.isNetworkRelated
|
||||||
|
import org.schabi.newpipe.util.ServiceHelper
|
||||||
import java.io.PrintWriter
|
import java.io.PrintWriter
|
||||||
import java.io.StringWriter
|
import java.io.StringWriter
|
||||||
|
|
||||||
|
@ -65,7 +65,7 @@ class ErrorInfo(
|
||||||
constructor(throwable: Throwable, userAction: UserAction, request: String) :
|
constructor(throwable: Throwable, userAction: UserAction, request: String) :
|
||||||
this(throwable, userAction, SERVICE_NONE, request)
|
this(throwable, userAction, SERVICE_NONE, request)
|
||||||
constructor(throwable: Throwable, userAction: UserAction, request: String, serviceId: Int) :
|
constructor(throwable: Throwable, userAction: UserAction, request: String, serviceId: Int) :
|
||||||
this(throwable, userAction, NewPipe.getNameOfService(serviceId), request)
|
this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request)
|
||||||
constructor(throwable: Throwable, userAction: UserAction, request: String, info: Info?) :
|
constructor(throwable: Throwable, userAction: UserAction, request: String, info: Info?) :
|
||||||
this(throwable, userAction, getInfoServiceName(info), request)
|
this(throwable, userAction, getInfoServiceName(info), request)
|
||||||
|
|
||||||
|
@ -73,7 +73,7 @@ class ErrorInfo(
|
||||||
constructor(throwable: List<Throwable>, userAction: UserAction, request: String) :
|
constructor(throwable: List<Throwable>, userAction: UserAction, request: String) :
|
||||||
this(throwable, userAction, SERVICE_NONE, request)
|
this(throwable, userAction, SERVICE_NONE, request)
|
||||||
constructor(throwable: List<Throwable>, userAction: UserAction, request: String, serviceId: Int) :
|
constructor(throwable: List<Throwable>, userAction: UserAction, request: String, serviceId: Int) :
|
||||||
this(throwable, userAction, NewPipe.getNameOfService(serviceId), request)
|
this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request)
|
||||||
constructor(throwable: List<Throwable>, userAction: UserAction, request: String, info: Info?) :
|
constructor(throwable: List<Throwable>, userAction: UserAction, request: String, info: Info?) :
|
||||||
this(throwable, userAction, getInfoServiceName(info), request)
|
this(throwable, userAction, getInfoServiceName(info), request)
|
||||||
|
|
||||||
|
@ -95,7 +95,7 @@ class ErrorInfo(
|
||||||
Array(throwable.size) { i -> getStackTrace(throwable[i]) }
|
Array(throwable.size) { i -> getStackTrace(throwable[i]) }
|
||||||
|
|
||||||
private fun getInfoServiceName(info: Info?) =
|
private fun getInfoServiceName(info: Info?) =
|
||||||
if (info == null) SERVICE_NONE else NewPipe.getNameOfService(info.serviceId)
|
if (info == null) SERVICE_NONE else ServiceHelper.getNameOfServiceById(info.serviceId)
|
||||||
|
|
||||||
@StringRes
|
@StringRes
|
||||||
private fun getMessageStringId(
|
private fun getMessageStringId(
|
||||||
|
|
|
@ -15,7 +15,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
import io.reactivex.rxjava3.disposables.Disposable
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
import org.schabi.newpipe.MainActivity
|
import org.schabi.newpipe.MainActivity
|
||||||
import org.schabi.newpipe.R
|
import org.schabi.newpipe.R
|
||||||
import org.schabi.newpipe.extractor.NewPipe
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||||
import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
|
import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
|
||||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||||
|
@ -106,7 +105,7 @@ class ErrorPanelHelper(
|
||||||
if (!isNullOrEmpty((errorInfo.throwable as AccountTerminatedException).message)) {
|
if (!isNullOrEmpty((errorInfo.throwable as AccountTerminatedException).message)) {
|
||||||
errorServiceInfoTextView.text = context.resources.getString(
|
errorServiceInfoTextView.text = context.resources.getString(
|
||||||
R.string.service_provides_reason,
|
R.string.service_provides_reason,
|
||||||
NewPipe.getNameOfService(ServiceHelper.getSelectedServiceId(context))
|
ServiceHelper.getSelectedService(context)?.serviceInfo?.name ?: "<unknown>"
|
||||||
)
|
)
|
||||||
errorServiceInfoTextView.isVisible = true
|
errorServiceInfoTextView.isVisible = true
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -84,7 +84,7 @@ public class DescriptionFragment extends BaseFragment {
|
||||||
private void setupDescription() {
|
private void setupDescription() {
|
||||||
final Description description = streamInfo.getDescription();
|
final Description description = streamInfo.getDescription();
|
||||||
if (description == null || isEmpty(description.getContent())
|
if (description == null || isEmpty(description.getContent())
|
||||||
|| description == Description.emptyDescription) {
|
|| description == Description.EMPTY_DESCRIPTION) {
|
||||||
binding.detailDescriptionView.setVisibility(View.GONE);
|
binding.detailDescriptionView.setVisibility(View.GONE);
|
||||||
binding.detailSelectDescriptionButton.setVisibility(View.GONE);
|
binding.detailSelectDescriptionButton.setVisibility(View.GONE);
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -31,6 +31,7 @@ import android.view.WindowManager;
|
||||||
import android.view.animation.DecelerateInterpolator;
|
import android.view.animation.DecelerateInterpolator;
|
||||||
import android.widget.FrameLayout;
|
import android.widget.FrameLayout;
|
||||||
import android.widget.RelativeLayout;
|
import android.widget.RelativeLayout;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
import androidx.annotation.AttrRes;
|
import androidx.annotation.AttrRes;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
@ -43,7 +44,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;
|
||||||
|
@ -97,6 +98,7 @@ import org.schabi.newpipe.util.Localization;
|
||||||
import org.schabi.newpipe.util.NavigationHelper;
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
import org.schabi.newpipe.util.PermissionHelper;
|
import org.schabi.newpipe.util.PermissionHelper;
|
||||||
import org.schabi.newpipe.util.PicassoHelper;
|
import org.schabi.newpipe.util.PicassoHelper;
|
||||||
|
import org.schabi.newpipe.util.StreamTypeUtil;
|
||||||
import org.schabi.newpipe.util.SponsorBlockUtils;
|
import org.schabi.newpipe.util.SponsorBlockUtils;
|
||||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||||
import org.schabi.newpipe.util.ThemeHelper;
|
import org.schabi.newpipe.util.ThemeHelper;
|
||||||
|
@ -125,6 +127,7 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientat
|
||||||
import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfirmationRequired;
|
import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfirmationRequired;
|
||||||
import static org.schabi.newpipe.player.playqueue.PlayQueueItem.RECOVERY_UNSET;
|
import static org.schabi.newpipe.player.playqueue.PlayQueueItem.RECOVERY_UNSET;
|
||||||
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
|
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
|
||||||
|
import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams;
|
||||||
|
|
||||||
public final class VideoDetailFragment
|
public final class VideoDetailFragment
|
||||||
extends BaseStateFragment<StreamInfo>
|
extends BaseStateFragment<StreamInfo>
|
||||||
|
@ -192,8 +195,6 @@ public final class VideoDetailFragment
|
||||||
@Nullable
|
@Nullable
|
||||||
private Disposable videoSegmentsSubscriber = null;
|
private Disposable videoSegmentsSubscriber = null;
|
||||||
|
|
||||||
private List<VideoStream> sortedVideoStreams;
|
|
||||||
private int selectedVideoStreamIndex = -1;
|
|
||||||
private BottomSheetBehavior<FrameLayout> bottomSheetBehavior;
|
private BottomSheetBehavior<FrameLayout> bottomSheetBehavior;
|
||||||
private BroadcastReceiver broadcastReceiver;
|
private BroadcastReceiver broadcastReceiver;
|
||||||
|
|
||||||
|
@ -672,8 +673,7 @@ public final class VideoDetailFragment
|
||||||
binding.detailControlsCrashThePlayer.setOnClickListener(
|
binding.detailControlsCrashThePlayer.setOnClickListener(
|
||||||
v -> VideoDetailPlayerCrasher.onCrashThePlayer(
|
v -> VideoDetailPlayerCrasher.onCrashThePlayer(
|
||||||
this.getContext(),
|
this.getContext(),
|
||||||
this.player,
|
this.player)
|
||||||
getLayoutInflater())
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1102,9 +1102,6 @@ public final class VideoDetailFragment
|
||||||
}
|
}
|
||||||
|
|
||||||
private void openBackgroundPlayer(final boolean append) {
|
private void openBackgroundPlayer(final boolean append) {
|
||||||
final AudioStream audioStream = currentInfo.getAudioStreams()
|
|
||||||
.get(ListHelper.getDefaultAudioFormat(activity, currentInfo.getAudioStreams()));
|
|
||||||
|
|
||||||
final boolean useExternalAudioPlayer = PreferenceManager
|
final boolean useExternalAudioPlayer = PreferenceManager
|
||||||
.getDefaultSharedPreferences(activity)
|
.getDefaultSharedPreferences(activity)
|
||||||
.getBoolean(activity.getString(R.string.use_external_audio_player_key), false);
|
.getBoolean(activity.getString(R.string.use_external_audio_player_key), false);
|
||||||
|
@ -1119,7 +1116,17 @@ public final class VideoDetailFragment
|
||||||
if (!useExternalAudioPlayer) {
|
if (!useExternalAudioPlayer) {
|
||||||
openNormalBackgroundPlayer(append);
|
openNormalBackgroundPlayer(append);
|
||||||
} else {
|
} else {
|
||||||
startOnExternalPlayer(activity, currentInfo, audioStream);
|
final List<AudioStream> audioStreams = getUrlAndNonTorrentStreams(
|
||||||
|
currentInfo.getAudioStreams());
|
||||||
|
final int index = ListHelper.getDefaultAudioFormat(activity, audioStreams);
|
||||||
|
|
||||||
|
if (index == -1) {
|
||||||
|
Toast.makeText(activity, R.string.no_audio_streams_available_for_external_players,
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startOnExternalPlayer(activity, currentInfo, audioStreams.get(index));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1635,13 +1642,6 @@ public final class VideoDetailFragment
|
||||||
binding.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE);
|
binding.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE);
|
||||||
binding.detailSecondaryControlPanel.setVisibility(View.GONE);
|
binding.detailSecondaryControlPanel.setVisibility(View.GONE);
|
||||||
|
|
||||||
sortedVideoStreams = ListHelper.getSortedStreamVideosList(
|
|
||||||
activity,
|
|
||||||
info.getVideoStreams(),
|
|
||||||
info.getVideoOnlyStreams(),
|
|
||||||
false);
|
|
||||||
selectedVideoStreamIndex = ListHelper
|
|
||||||
.getDefaultResolutionIndex(activity, sortedVideoStreams);
|
|
||||||
updateProgressInfo(info);
|
updateProgressInfo(info);
|
||||||
initThumbnailViews(info);
|
initThumbnailViews(info);
|
||||||
showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView,
|
showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView,
|
||||||
|
@ -1667,8 +1667,8 @@ public final class VideoDetailFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.detailControlsDownload.setVisibility(info.getStreamType() == StreamType.LIVE_STREAM
|
binding.detailControlsDownload.setVisibility(
|
||||||
|| info.getStreamType() == StreamType.AUDIO_LIVE_STREAM ? View.GONE : View.VISIBLE);
|
StreamTypeUtil.isLiveStream(info.getStreamType()) ? View.GONE : View.VISIBLE);
|
||||||
binding.detailControlsBackground.setVisibility(info.getAudioStreams().isEmpty()
|
binding.detailControlsBackground.setVisibility(info.getAudioStreams().isEmpty()
|
||||||
? View.GONE : View.VISIBLE);
|
? View.GONE : View.VISIBLE);
|
||||||
|
|
||||||
|
@ -1726,18 +1726,12 @@ public final class VideoDetailFragment
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(videoSegments -> {
|
.subscribe(videoSegments -> {
|
||||||
try {
|
try {
|
||||||
final DownloadDialog downloadDialog = DownloadDialog.newInstance(currentInfo);
|
final DownloadDialog downloadDialog = new DownloadDialog(activity, currentInfo);
|
||||||
downloadDialog.setVideoStreams(sortedVideoStreams);
|
|
||||||
downloadDialog.setAudioStreams(currentInfo.getAudioStreams());
|
|
||||||
downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex);
|
|
||||||
downloadDialog.setSubtitleStreams(currentInfo.getSubtitles());
|
|
||||||
downloadDialog.setVideoSegments(videoSegments);
|
downloadDialog.setVideoSegments(videoSegments);
|
||||||
downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog");
|
downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog");
|
||||||
} catch (final Exception e) {
|
} catch (final Exception e) {
|
||||||
ErrorUtil.showSnackbar(activity,
|
ErrorUtil.showSnackbar(activity, new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG,
|
||||||
new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG,
|
"Showing download dialog", currentInfo));
|
||||||
"Showing download dialog",
|
|
||||||
currentInfo));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1764,8 +1758,7 @@ public final class VideoDetailFragment
|
||||||
binding.detailPositionView.setVisibility(View.GONE);
|
binding.detailPositionView.setVisibility(View.GONE);
|
||||||
// TODO: Remove this check when separation of concerns is done.
|
// TODO: Remove this check when separation of concerns is done.
|
||||||
// (live streams weren't getting updated because they are mixed)
|
// (live streams weren't getting updated because they are mixed)
|
||||||
if (!info.getStreamType().equals(StreamType.LIVE_STREAM)
|
if (!StreamTypeUtil.isLiveStream(info.getStreamType())) {
|
||||||
&& !info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -1925,9 +1918,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();
|
||||||
|
@ -2194,25 +2186,52 @@ public final class VideoDetailFragment
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showExternalPlaybackDialog() {
|
private void showExternalPlaybackDialog() {
|
||||||
if (sortedVideoStreams == null) {
|
if (currentInfo == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final CharSequence[] resolutions = new CharSequence[sortedVideoStreams.size()];
|
|
||||||
for (int i = 0; i < sortedVideoStreams.size(); i++) {
|
final AlertDialog.Builder builder = new AlertDialog.Builder(activity);
|
||||||
resolutions[i] = sortedVideoStreams.get(i).getResolution();
|
builder.setTitle(R.string.select_quality_external_players);
|
||||||
}
|
builder.setNeutralButton(R.string.open_in_browser, (dialog, i) ->
|
||||||
final AlertDialog.Builder builder = new AlertDialog.Builder(activity)
|
ShareUtils.openUrlInBrowser(requireActivity(), url));
|
||||||
.setNegativeButton(R.string.cancel, null)
|
|
||||||
.setNeutralButton(R.string.open_in_browser, (dialog, i) ->
|
final List<VideoStream> videoStreamsForExternalPlayers =
|
||||||
ShareUtils.openUrlInBrowser(requireActivity(), url)
|
ListHelper.getSortedStreamVideosList(
|
||||||
|
activity,
|
||||||
|
getUrlAndNonTorrentStreams(currentInfo.getVideoStreams()),
|
||||||
|
getUrlAndNonTorrentStreams(currentInfo.getVideoOnlyStreams()),
|
||||||
|
false,
|
||||||
|
false
|
||||||
);
|
);
|
||||||
// Maybe there are no video streams available, show just `open in browser` button
|
|
||||||
if (resolutions.length > 0) {
|
if (videoStreamsForExternalPlayers.isEmpty()) {
|
||||||
builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndex, (dialog, i) -> {
|
builder.setMessage(R.string.no_video_streams_available_for_external_players);
|
||||||
dialog.dismiss();
|
builder.setPositiveButton(R.string.ok, null);
|
||||||
startOnExternalPlayer(activity, currentInfo, sortedVideoStreams.get(i));
|
|
||||||
|
} else {
|
||||||
|
final int selectedVideoStreamIndexForExternalPlayers =
|
||||||
|
ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers);
|
||||||
|
final CharSequence[] resolutions =
|
||||||
|
new CharSequence[videoStreamsForExternalPlayers.size()];
|
||||||
|
|
||||||
|
for (int i = 0; i < videoStreamsForExternalPlayers.size(); i++) {
|
||||||
|
resolutions[i] = videoStreamsForExternalPlayers.get(i).getResolution();
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndexForExternalPlayers,
|
||||||
|
null);
|
||||||
|
builder.setNegativeButton(R.string.cancel, null);
|
||||||
|
builder.setPositiveButton(R.string.ok, (dialog, i) -> {
|
||||||
|
final int index = ((AlertDialog) dialog).getListView().getCheckedItemPosition();
|
||||||
|
// We don't have to manage the index validity because if there is no stream
|
||||||
|
// available for external players, this code will be not executed and if there is
|
||||||
|
// no stream which matches the default resolution, 0 is returned by
|
||||||
|
// ListHelper.getDefaultResolutionIndex.
|
||||||
|
// The index cannot be outside the bounds of the list as its always between 0 and
|
||||||
|
// the list size - 1, .
|
||||||
|
startOnExternalPlayer(activity, currentInfo,
|
||||||
|
videoStreamsForExternalPlayers.get(index));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
builder.show();
|
builder.show();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
package org.schabi.newpipe.fragments.detail;
|
package org.schabi.newpipe.fragments.detail;
|
||||||
|
|
||||||
|
import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW;
|
||||||
|
import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_DECODING_FAILED;
|
||||||
|
import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_UNSPECIFIED;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.ContextThemeWrapper;
|
import android.view.ContextThemeWrapper;
|
||||||
|
@ -15,6 +19,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;
|
||||||
|
@ -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(
|
||||||
|
@ -88,8 +97,7 @@ public final class VideoDetailPlayerCrasher {
|
||||||
|
|
||||||
public static void onCrashThePlayer(
|
public static void onCrashThePlayer(
|
||||||
@NonNull final Context context,
|
@NonNull final Context context,
|
||||||
@Nullable final Player player,
|
@Nullable final Player player
|
||||||
@NonNull final LayoutInflater layoutInflater
|
|
||||||
) {
|
) {
|
||||||
if (player == null) {
|
if (player == null) {
|
||||||
Log.d(TAG, "Player is not available");
|
Log.d(TAG, "Player is not available");
|
||||||
|
@ -100,16 +108,15 @@ public final class VideoDetailPlayerCrasher {
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Build the dialog/UI --
|
// -- Build the dialog/UI --
|
||||||
|
|
||||||
final Context themeWrapperContext = getThemeWrapperContext(context);
|
final Context themeWrapperContext = getThemeWrapperContext(context);
|
||||||
|
|
||||||
final LayoutInflater inflater = LayoutInflater.from(themeWrapperContext);
|
final LayoutInflater inflater = LayoutInflater.from(themeWrapperContext);
|
||||||
final RadioGroup radioGroup = SingleChoiceDialogViewBinding.inflate(layoutInflater)
|
|
||||||
.list;
|
|
||||||
|
|
||||||
final AlertDialog alertDialog = new AlertDialog.Builder(getThemeWrapperContext(context))
|
final SingleChoiceDialogViewBinding binding =
|
||||||
|
SingleChoiceDialogViewBinding.inflate(inflater);
|
||||||
|
|
||||||
|
final AlertDialog alertDialog = new AlertDialog.Builder(themeWrapperContext)
|
||||||
.setTitle("Choose an exception")
|
.setTitle("Choose an exception")
|
||||||
.setView(radioGroup)
|
.setView(binding.getRoot())
|
||||||
.setCancelable(true)
|
.setCancelable(true)
|
||||||
.setNegativeButton(R.string.cancel, null)
|
.setNegativeButton(R.string.cancel, null)
|
||||||
.create();
|
.create();
|
||||||
|
@ -127,11 +134,9 @@ public final class VideoDetailPlayerCrasher {
|
||||||
);
|
);
|
||||||
radioButton.setOnClickListener(v -> {
|
radioButton.setOnClickListener(v -> {
|
||||||
tryCrashPlayerWith(player, entry.getValue().get());
|
tryCrashPlayerWith(player, entry.getValue().get());
|
||||||
if (alertDialog != null) {
|
|
||||||
alertDialog.cancel();
|
alertDialog.cancel();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
radioGroup.addView(radioButton);
|
binding.list.addView(radioButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
alertDialog.show();
|
alertDialog.show();
|
||||||
|
@ -139,7 +144,7 @@ public final class VideoDetailPlayerCrasher {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Note that this method does not crash the underlying exoplayer directly (it's not possible).
|
* 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;
|
||||||
|
@ -73,6 +77,8 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||||
private Disposable subscribeButtonMonitor;
|
private Disposable subscribeButtonMonitor;
|
||||||
|
|
||||||
|
private boolean channelContentNotSupported = false;
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
// Views
|
// Views
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
@ -84,6 +90,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) {
|
||||||
|
@ -125,6 +132,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||||
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
|
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
|
||||||
super.onViewCreated(rootView, savedInstanceState);
|
super.onViewCreated(rootView, savedInstanceState);
|
||||||
channelBinding = FragmentChannelBinding.bind(rootView);
|
channelBinding = FragmentChannelBinding.bind(rootView);
|
||||||
|
showContentNotSupportedIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -179,6 +187,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 +197,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 +246,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 +341,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 +349,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 +392,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -465,9 +527,12 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||||
playlistControlBinding.getRoot().setVisibility(View.GONE);
|
playlistControlBinding.getRoot().setVisibility(View.GONE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
channelContentNotSupported = false;
|
||||||
for (final Throwable throwable : result.getErrors()) {
|
for (final Throwable throwable : result.getErrors()) {
|
||||||
if (throwable instanceof ContentNotSupportedException) {
|
if (throwable instanceof ContentNotSupportedException) {
|
||||||
showContentNotSupported();
|
channelContentNotSupported = true;
|
||||||
|
showContentNotSupportedIfNeeded();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -499,7 +564,13 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showContentNotSupported() {
|
private void showContentNotSupportedIfNeeded() {
|
||||||
|
// channelBinding might not be initialized when handleResult() is called
|
||||||
|
// (e.g. after rotating the screen, #6696)
|
||||||
|
if (!channelContentNotSupported || channelBinding == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
channelBinding.errorContentNotSupported.setVisibility(View.VISIBLE);
|
channelBinding.errorContentNotSupported.setVisibility(View.VISIBLE);
|
||||||
channelBinding.channelKaomoji.setText("(︶︹︺)");
|
channelBinding.channelKaomoji.setText("(︶︹︺)");
|
||||||
channelBinding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f);
|
channelBinding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f);
|
||||||
|
|
|
@ -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,10 +1,8 @@
|
||||||
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.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
|
@ -20,11 +18,15 @@ 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 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;
|
||||||
import org.schabi.newpipe.NewPipeDatabase;
|
import org.schabi.newpipe.NewPipeDatabase;
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
|
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
|
||||||
|
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||||
import org.schabi.newpipe.databinding.PlaylistControlBinding;
|
import org.schabi.newpipe.databinding.PlaylistControlBinding;
|
||||||
import org.schabi.newpipe.databinding.PlaylistHeaderBinding;
|
import org.schabi.newpipe.databinding.PlaylistHeaderBinding;
|
||||||
import org.schabi.newpipe.error.ErrorInfo;
|
import org.schabi.newpipe.error.ErrorInfo;
|
||||||
|
@ -36,27 +38,25 @@ import org.schabi.newpipe.extractor.ServiceList;
|
||||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
|
import org.schabi.newpipe.extractor.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.info_list.dialog.StreamDialogDefaultEntry;
|
||||||
|
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||||
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.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;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
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;
|
||||||
|
@ -64,7 +64,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 +140,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 +211,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -276,6 +238,17 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||||
case R.id.menu_item_bookmark:
|
case R.id.menu_item_bookmark:
|
||||||
onBookmarkClicked();
|
onBookmarkClicked();
|
||||||
break;
|
break;
|
||||||
|
case R.id.menu_item_append_playlist:
|
||||||
|
disposables.add(PlaylistDialog.createCorrespondingDialog(
|
||||||
|
getContext(),
|
||||||
|
getPlayQueue()
|
||||||
|
.getStreams()
|
||||||
|
.stream()
|
||||||
|
.map(StreamEntity::new)
|
||||||
|
.collect(Collectors.toList()),
|
||||||
|
dialog -> dialog.show(getFM(), TAG)
|
||||||
|
));
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return super.onOptionsItemSelected(item);
|
return super.onOptionsItemSelected(item);
|
||||||
}
|
}
|
||||||
|
@ -328,9 +301,12 @@ 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(AppCompatResources
|
||||||
|
.getColorStateList(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,354 @@
|
||||||
|
package org.schabi.newpipe.info_list.dialog;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.DialogInterface;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.appcompat.app.AlertDialog;
|
||||||
|
import androidx.fragment.app.Fragment;
|
||||||
|
import androidx.preference.PreferenceManager;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.App;
|
||||||
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.error.ErrorInfo;
|
||||||
|
import org.schabi.newpipe.error.ErrorUtil;
|
||||||
|
import org.schabi.newpipe.error.UserAction;
|
||||||
|
import org.schabi.newpipe.extractor.InfoItem;
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||||
|
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||||
|
import org.schabi.newpipe.util.StreamTypeUtil;
|
||||||
|
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog for a {@link StreamInfoItem}.
|
||||||
|
* The dialog's content are actions that can be performed on the {@link StreamInfoItem}.
|
||||||
|
* This dialog is mostly used for longpress context menus.
|
||||||
|
*/
|
||||||
|
public final class InfoItemDialog {
|
||||||
|
private static final String TAG = Build.class.getSimpleName();
|
||||||
|
/**
|
||||||
|
* Ideally, {@link InfoItemDialog} would extend {@link AlertDialog}.
|
||||||
|
* However, extending {@link AlertDialog} requires many additional lines
|
||||||
|
* and brings more complexity to this class, especially the constructor.
|
||||||
|
* To circumvent this, an {@link AlertDialog.Builder} is used in the constructor.
|
||||||
|
* Its result is stored in this class variable to allow access via the {@link #show()} method.
|
||||||
|
*/
|
||||||
|
private final AlertDialog dialog;
|
||||||
|
|
||||||
|
private InfoItemDialog(@NonNull final Activity activity,
|
||||||
|
@NonNull final Fragment fragment,
|
||||||
|
@NonNull final StreamInfoItem info,
|
||||||
|
@NonNull final List<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 (!StreamTypeUtil.isAudio(infoItem.getStreamType())) {
|
||||||
|
addEntry(StreamDialogDefaultEntry.START_HERE_ON_POPUP);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds {@link StreamDialogDefaultEntry#MARK_AS_WATCHED} if the watch history is enabled
|
||||||
|
* and the stream is not a livestream.
|
||||||
|
* @return the current {@link Builder} instance
|
||||||
|
*/
|
||||||
|
public Builder addMarkAsWatchedEntryIfNeeded() {
|
||||||
|
final boolean isWatchHistoryEnabled = PreferenceManager
|
||||||
|
.getDefaultSharedPreferences(context)
|
||||||
|
.getBoolean(context.getString(R.string.enable_watch_history_key), false);
|
||||||
|
if (isWatchHistoryEnabled && !StreamTypeUtil.isLiveStream(infoItem.getStreamType())) {
|
||||||
|
addEntry(StreamDialogDefaultEntry.MARK_AS_WATCHED);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the {@link StreamDialogDefaultEntry#PLAY_WITH_KODI} entry if it is needed.
|
||||||
|
* @return the current {@link Builder} instance
|
||||||
|
*/
|
||||||
|
public Builder addPlayWithKodiEntryIfNeeded() {
|
||||||
|
if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
|
||||||
|
addEntry(StreamDialogDefaultEntry.PLAY_WITH_KODI);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the entries which are usually at the top of the action list.
|
||||||
|
* <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;
|
||||||
|
|
|
@ -11,12 +11,12 @@ import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity;
|
import org.schabi.newpipe.database.stream.model.StreamStateEntity;
|
||||||
import org.schabi.newpipe.extractor.InfoItem;
|
import org.schabi.newpipe.extractor.InfoItem;
|
||||||
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.InfoItemBuilder;
|
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||||
import org.schabi.newpipe.ktx.ViewUtils;
|
import org.schabi.newpipe.ktx.ViewUtils;
|
||||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
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 org.schabi.newpipe.util.StreamTypeUtil;
|
||||||
import org.schabi.newpipe.views.AnimatedProgressBar;
|
import org.schabi.newpipe.views.AnimatedProgressBar;
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
@ -70,8 +70,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
||||||
} else {
|
} else {
|
||||||
itemProgressView.setVisibility(View.GONE);
|
itemProgressView.setVisibility(View.GONE);
|
||||||
}
|
}
|
||||||
} else if (item.getStreamType() == StreamType.LIVE_STREAM
|
} else if (StreamTypeUtil.isLiveStream(item.getStreamType())) {
|
||||||
|| item.getStreamType() == StreamType.AUDIO_LIVE_STREAM) {
|
|
||||||
itemDurationView.setText(R.string.duration_live);
|
itemDurationView.setText(R.string.duration_live);
|
||||||
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
|
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
|
||||||
R.color.live_duration_background_color));
|
R.color.live_duration_background_color));
|
||||||
|
@ -96,9 +95,10 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
||||||
case VIDEO_STREAM:
|
case VIDEO_STREAM:
|
||||||
case LIVE_STREAM:
|
case LIVE_STREAM:
|
||||||
case AUDIO_LIVE_STREAM:
|
case AUDIO_LIVE_STREAM:
|
||||||
|
case POST_LIVE_STREAM:
|
||||||
|
case POST_LIVE_AUDIO_STREAM:
|
||||||
enableLongClick(item);
|
enableLongClick(item);
|
||||||
break;
|
break;
|
||||||
case FILE:
|
|
||||||
case NONE:
|
case NONE:
|
||||||
default:
|
default:
|
||||||
disableLongClick();
|
disableLongClick();
|
||||||
|
@ -114,7 +114,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
||||||
final StreamStateEntity state
|
final StreamStateEntity state
|
||||||
= historyRecordManager.loadStreamState(infoItem).blockingGet()[0];
|
= historyRecordManager.loadStreamState(infoItem).blockingGet()[0];
|
||||||
if (state != null && item.getDuration() > 0
|
if (state != null && item.getDuration() > 0
|
||||||
&& item.getStreamType() != StreamType.LIVE_STREAM) {
|
&& !StreamTypeUtil.isLiveStream(item.getStreamType())) {
|
||||||
itemProgressView.setMax((int) item.getDuration());
|
itemProgressView.setMax((int) item.getDuration());
|
||||||
if (itemProgressView.getVisibility() == View.VISIBLE) {
|
if (itemProgressView.getVisibility() == View.VISIBLE) {
|
||||||
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
|
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
|
||||||
|
|
|
@ -300,14 +300,7 @@ private fun View.animateLightSlideAndAlpha(enterOrExit: Boolean, duration: Long,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun View.slideUp(
|
@JvmOverloads
|
||||||
duration: Long,
|
|
||||||
delay: Long,
|
|
||||||
@FloatRange(from = 0.0, to = 1.0) translationPercent: Float
|
|
||||||
) {
|
|
||||||
slideUp(duration, delay, translationPercent, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun View.slideUp(
|
fun View.slideUp(
|
||||||
duration: Long,
|
duration: Long,
|
||||||
delay: Long = 0L,
|
delay: Long = 0L,
|
||||||
|
|
|
@ -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>,
|
||||||
|
|
|
@ -25,7 +25,6 @@ import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import android.graphics.drawable.LayerDrawable
|
import android.graphics.drawable.LayerDrawable
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
|
@ -37,7 +36,6 @@ import android.view.MenuItem
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
import androidx.annotation.AttrRes
|
|
||||||
import androidx.annotation.Nullable
|
import androidx.annotation.Nullable
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.content.res.AppCompatResources
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
|
@ -50,7 +48,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 +65,22 @@ 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.resolveDrawable
|
||||||
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 +137,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 +350,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 +371,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 +391,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,
|
|
||||||
OnAsyncUpdateListener {
|
|
||||||
oldOldestSubscriptionUpdate?.run {
|
oldOldestSubscriptionUpdate?.run {
|
||||||
highlightNewItemsAfter(oldOldestSubscriptionUpdate)
|
highlightNewItemsAfter(oldOldestSubscriptionUpdate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
listState?.run {
|
listState?.run {
|
||||||
feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState)
|
feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState)
|
||||||
|
@ -497,8 +447,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,
|
||||||
|
@ -629,19 +578,6 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||||
lastNewItemsCount = highlightCount
|
lastNewItemsCount = highlightCount
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveDrawable(context: Context, @AttrRes attrResId: Int): Drawable? {
|
|
||||||
return androidx.core.content.ContextCompat.getDrawable(
|
|
||||||
context,
|
|
||||||
android.util.TypedValue().apply {
|
|
||||||
context.theme.resolveAttribute(
|
|
||||||
attrResId,
|
|
||||||
this,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
}.resourceId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showNewItemsLoaded() {
|
private fun showNewItemsLoaded() {
|
||||||
tryGetNewItemsLoadedButton()?.clearAnimation()
|
tryGetNewItemsLoadedButton()?.clearAnimation()
|
||||||
tryGetNewItemsLoadedButton()
|
tryGetNewItemsLoadedButton()
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -14,6 +14,8 @@ import org.schabi.newpipe.databinding.ListStreamItemBinding
|
||||||
import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM
|
import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM
|
||||||
import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_STREAM
|
import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_STREAM
|
||||||
import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM
|
import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_AUDIO_STREAM
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_STREAM
|
||||||
import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM
|
import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM
|
||||||
import org.schabi.newpipe.util.Localization
|
import org.schabi.newpipe.util.Localization
|
||||||
import org.schabi.newpipe.util.PicassoHelper
|
import org.schabi.newpipe.util.PicassoHelper
|
||||||
|
@ -109,7 +111,7 @@ data class StreamItem(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isLongClickable() = when (stream.streamType) {
|
override fun isLongClickable() = when (stream.streamType) {
|
||||||
AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM -> true
|
AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM, POST_LIVE_STREAM, POST_LIVE_AUDIO_STREAM -> true
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -128,13 +128,11 @@ public class HistoryRecordManager {
|
||||||
|
|
||||||
// Add a history entry
|
// Add a history entry
|
||||||
final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId);
|
final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId);
|
||||||
if (latestEntry != null) {
|
if (latestEntry == null) {
|
||||||
streamHistoryTable.delete(latestEntry);
|
// never actually viewed: add history entry but with 0 views
|
||||||
latestEntry.setAccessDate(currentTime);
|
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 0));
|
||||||
latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1);
|
|
||||||
return streamHistoryTable.insert(latestEntry);
|
|
||||||
} else {
|
} else {
|
||||||
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime));
|
return 0L;
|
||||||
}
|
}
|
||||||
})).subscribeOn(Schedulers.io());
|
})).subscribeOn(Schedulers.io());
|
||||||
}
|
}
|
||||||
|
@ -155,7 +153,8 @@ public class HistoryRecordManager {
|
||||||
latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1);
|
latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1);
|
||||||
return streamHistoryTable.insert(latestEntry);
|
return streamHistoryTable.insert(latestEntry);
|
||||||
} else {
|
} else {
|
||||||
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime));
|
// just viewed for the first time: set 1 view
|
||||||
|
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 1));
|
||||||
}
|
}
|
||||||
})).subscribeOn(Schedulers.io());
|
})).subscribeOn(Schedulers.io());
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,68 +323,32 @@ 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) {
|
||||||
final LocalItem infoItem = itemListAdapter.getItemsList().get(index);
|
final LocalItem infoItem = itemListAdapter.getItemsList().get(index);
|
||||||
if (infoItem instanceof StreamStatisticsEntry) {
|
if (infoItem instanceof StreamStatisticsEntry) {
|
||||||
|
|
|
@ -11,12 +11,12 @@ import androidx.core.content.ContextCompat;
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.database.LocalItem;
|
import org.schabi.newpipe.database.LocalItem;
|
||||||
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
|
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
|
||||||
import org.schabi.newpipe.extractor.NewPipe;
|
|
||||||
import org.schabi.newpipe.ktx.ViewUtils;
|
import org.schabi.newpipe.ktx.ViewUtils;
|
||||||
import org.schabi.newpipe.local.LocalItemBuilder;
|
import org.schabi.newpipe.local.LocalItemBuilder;
|
||||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||||
import org.schabi.newpipe.util.PicassoHelper;
|
|
||||||
import org.schabi.newpipe.util.Localization;
|
import org.schabi.newpipe.util.Localization;
|
||||||
|
import org.schabi.newpipe.util.PicassoHelper;
|
||||||
|
import org.schabi.newpipe.util.ServiceHelper;
|
||||||
import org.schabi.newpipe.views.AnimatedProgressBar;
|
import org.schabi.newpipe.views.AnimatedProgressBar;
|
||||||
|
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
@ -59,7 +59,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
|
||||||
itemVideoTitleView.setText(item.getStreamEntity().getTitle());
|
itemVideoTitleView.setText(item.getStreamEntity().getTitle());
|
||||||
itemAdditionalDetailsView.setText(Localization
|
itemAdditionalDetailsView.setText(Localization
|
||||||
.concatenateStrings(item.getStreamEntity().getUploader(),
|
.concatenateStrings(item.getStreamEntity().getUploader(),
|
||||||
NewPipe.getNameOfService(item.getStreamEntity().getServiceId())));
|
ServiceHelper.getNameOfServiceById(item.getStreamEntity().getServiceId())));
|
||||||
|
|
||||||
if (item.getStreamEntity().getDuration() > 0) {
|
if (item.getStreamEntity().getDuration() > 0) {
|
||||||
itemDurationView.setText(Localization
|
itemDurationView.setText(Localization
|
||||||
|
|
|
@ -11,12 +11,12 @@ import androidx.core.content.ContextCompat;
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.database.LocalItem;
|
import org.schabi.newpipe.database.LocalItem;
|
||||||
import org.schabi.newpipe.database.stream.StreamStatisticsEntry;
|
import org.schabi.newpipe.database.stream.StreamStatisticsEntry;
|
||||||
import org.schabi.newpipe.extractor.NewPipe;
|
|
||||||
import org.schabi.newpipe.ktx.ViewUtils;
|
import org.schabi.newpipe.ktx.ViewUtils;
|
||||||
import org.schabi.newpipe.local.LocalItemBuilder;
|
import org.schabi.newpipe.local.LocalItemBuilder;
|
||||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||||
import org.schabi.newpipe.util.PicassoHelper;
|
|
||||||
import org.schabi.newpipe.util.Localization;
|
import org.schabi.newpipe.util.Localization;
|
||||||
|
import org.schabi.newpipe.util.PicassoHelper;
|
||||||
|
import org.schabi.newpipe.util.ServiceHelper;
|
||||||
import org.schabi.newpipe.views.AnimatedProgressBar;
|
import org.schabi.newpipe.views.AnimatedProgressBar;
|
||||||
|
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
@ -70,11 +70,12 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
|
||||||
|
|
||||||
private String getStreamInfoDetailLine(final StreamStatisticsEntry entry,
|
private String getStreamInfoDetailLine(final StreamStatisticsEntry entry,
|
||||||
final DateTimeFormatter dateTimeFormatter) {
|
final DateTimeFormatter dateTimeFormatter) {
|
||||||
final String watchCount = Localization
|
return Localization.concatenateStrings(
|
||||||
.shortViewCount(itemBuilder.getContext(), entry.getWatchCount());
|
// watchCount
|
||||||
final String uploadDate = dateTimeFormatter.format(entry.getLatestAccessDate());
|
Localization.shortViewCount(itemBuilder.getContext(), entry.getWatchCount()),
|
||||||
final String serviceName = NewPipe.getNameOfService(entry.getStreamEntity().getServiceId());
|
dateTimeFormatter.format(entry.getLatestAccessDate()),
|
||||||
return Localization.concatenateStrings(watchCount, uploadDate, serviceName);
|
// serviceName
|
||||||
|
ServiceHelper.getNameOfServiceById(entry.getStreamEntity().getServiceId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -5,11 +5,11 @@ import android.view.ViewGroup;
|
||||||
|
|
||||||
import org.schabi.newpipe.database.LocalItem;
|
import org.schabi.newpipe.database.LocalItem;
|
||||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
|
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
|
||||||
import org.schabi.newpipe.extractor.NewPipe;
|
|
||||||
import org.schabi.newpipe.local.LocalItemBuilder;
|
import org.schabi.newpipe.local.LocalItemBuilder;
|
||||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||||
import org.schabi.newpipe.util.PicassoHelper;
|
|
||||||
import org.schabi.newpipe.util.Localization;
|
import org.schabi.newpipe.util.Localization;
|
||||||
|
import org.schabi.newpipe.util.PicassoHelper;
|
||||||
|
import org.schabi.newpipe.util.ServiceHelper;
|
||||||
|
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
@ -39,9 +39,9 @@ public class RemotePlaylistItemHolder extends PlaylistItemHolder {
|
||||||
// Here is where the uploader name is set in the bookmarked playlists library
|
// Here is where the uploader name is set in the bookmarked playlists library
|
||||||
if (!TextUtils.isEmpty(item.getUploader())) {
|
if (!TextUtils.isEmpty(item.getUploader())) {
|
||||||
itemUploaderView.setText(Localization.concatenateStrings(item.getUploader(),
|
itemUploaderView.setText(Localization.concatenateStrings(item.getUploader(),
|
||||||
NewPipe.getNameOfService(item.getServiceId())));
|
ServiceHelper.getNameOfServiceById(item.getServiceId())));
|
||||||
} else {
|
} else {
|
||||||
itemUploaderView.setText(NewPipe.getNameOfService(item.getServiceId()));
|
itemUploaderView.setText(ServiceHelper.getNameOfServiceById(item.getServiceId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView);
|
PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView);
|
||||||
|
|
|
@ -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,
|
||||||
|
@ -424,9 +419,11 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||||
final PlaylistStreamEntry playlistItem = playlistIter.next();
|
final PlaylistStreamEntry playlistItem = playlistIter.next();
|
||||||
final int indexInHistory = Collections.binarySearch(historyStreamIds,
|
final int indexInHistory = Collections.binarySearch(historyStreamIds,
|
||||||
playlistItem.getStreamId());
|
playlistItem.getStreamId());
|
||||||
|
final StreamStateEntity streamStateEntity = streamStatesIter.next();
|
||||||
|
final long duration = playlistItem.toStreamInfoItem().getDuration();
|
||||||
|
|
||||||
final boolean hasState = streamStatesIter.next() != null;
|
if (indexInHistory < 0 || (streamStateEntity != null
|
||||||
if (indexInHistory < 0 || hasState) {
|
&& !streamStateEntity.isFinished(duration))) {
|
||||||
notWatchedItems.add(playlistItem);
|
notWatchedItems.add(playlistItem);
|
||||||
} else if (!thumbnailVideoRemoved
|
} else if (!thumbnailVideoRemoved
|
||||||
&& playlistManager.getPlaylistThumbnail(playlistId)
|
&& playlistManager.getPlaylistThumbnail(playlistId)
|
||||||
|
@ -743,70 +740,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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,24 +1,24 @@
|
||||||
package org.schabi.newpipe.local.subscription
|
package org.schabi.newpipe.local.subscription
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.DialogInterface
|
import android.content.DialogInterface
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuInflater
|
import android.view.MenuInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.SubMenu
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.ActivityResult
|
import androidx.activity.result.ActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
|
||||||
import androidx.recyclerview.widget.GridLayoutManager
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
import com.xwray.groupie.Group
|
import com.xwray.groupie.Group
|
||||||
import com.xwray.groupie.GroupAdapter
|
import com.xwray.groupie.GroupAdapter
|
||||||
|
@ -34,6 +34,7 @@ import org.schabi.newpipe.databinding.FeedItemCarouselBinding
|
||||||
import org.schabi.newpipe.databinding.FragmentSubscriptionBinding
|
import org.schabi.newpipe.databinding.FragmentSubscriptionBinding
|
||||||
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.ServiceList
|
||||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
|
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
|
||||||
import org.schabi.newpipe.fragments.BaseStateFragment
|
import org.schabi.newpipe.fragments.BaseStateFragment
|
||||||
import org.schabi.newpipe.ktx.animate
|
import org.schabi.newpipe.ktx.animate
|
||||||
|
@ -45,13 +46,10 @@ import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem
|
||||||
import org.schabi.newpipe.local.subscription.item.FeedGroupAddItem
|
import org.schabi.newpipe.local.subscription.item.FeedGroupAddItem
|
||||||
import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem
|
import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem
|
||||||
import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem
|
import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem
|
||||||
import org.schabi.newpipe.local.subscription.item.FeedImportExportItem
|
|
||||||
import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem
|
import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem
|
||||||
import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem.Companion.PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM
|
import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem.Companion.PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM
|
||||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService
|
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService
|
||||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE_ACTION
|
|
||||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService
|
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService
|
||||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.IMPORT_COMPLETE_ACTION
|
|
||||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE
|
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE
|
||||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE
|
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE
|
||||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE
|
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE
|
||||||
|
@ -59,6 +57,7 @@ import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard
|
||||||
import org.schabi.newpipe.streams.io.StoredFileHelper
|
import org.schabi.newpipe.streams.io.StoredFileHelper
|
||||||
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.ServiceHelper
|
||||||
import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountChannels
|
import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountChannels
|
||||||
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
|
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
|
||||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||||
|
@ -74,12 +73,9 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||||
private lateinit var subscriptionManager: SubscriptionManager
|
private lateinit var subscriptionManager: SubscriptionManager
|
||||||
private val disposables: CompositeDisposable = CompositeDisposable()
|
private val disposables: CompositeDisposable = CompositeDisposable()
|
||||||
|
|
||||||
private var subscriptionBroadcastReceiver: BroadcastReceiver? = null
|
|
||||||
|
|
||||||
private val groupAdapter = GroupAdapter<GroupieViewHolder<FeedItemCarouselBinding>>()
|
private val groupAdapter = GroupAdapter<GroupieViewHolder<FeedItemCarouselBinding>>()
|
||||||
private val feedGroupsSection = Section()
|
private val feedGroupsSection = Section()
|
||||||
private var feedGroupsCarousel: FeedGroupCarouselItem? = null
|
private var feedGroupsCarousel: FeedGroupCarouselItem? = null
|
||||||
private lateinit var importExportItem: FeedImportExportItem
|
|
||||||
private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem
|
private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem
|
||||||
private val subscriptionsSection = Section()
|
private val subscriptionsSection = Section()
|
||||||
|
|
||||||
|
@ -91,12 +87,10 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||||
@State
|
@State
|
||||||
@JvmField
|
@JvmField
|
||||||
var itemsListState: Parcelable? = null
|
var itemsListState: Parcelable? = null
|
||||||
|
|
||||||
@State
|
@State
|
||||||
@JvmField
|
@JvmField
|
||||||
var feedGroupsListState: Parcelable? = null
|
var feedGroupsListState: Parcelable? = null
|
||||||
@State
|
|
||||||
@JvmField
|
|
||||||
var importExportItemExpandedState: Boolean? = null
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
setHasOptionsMenu(true)
|
setHasOptionsMenu(true)
|
||||||
|
@ -120,20 +114,10 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||||
return inflater.inflate(R.layout.fragment_subscription, container, false)
|
return inflater.inflate(R.layout.fragment_subscription, container, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
setupBroadcastReceiver()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
itemsListState = binding.itemsList.layoutManager?.onSaveInstanceState()
|
itemsListState = binding.itemsList.layoutManager?.onSaveInstanceState()
|
||||||
feedGroupsListState = feedGroupsCarousel?.onSaveInstanceState()
|
feedGroupsListState = feedGroupsCarousel?.onSaveInstanceState()
|
||||||
importExportItemExpandedState = importExportItem.isExpanded
|
|
||||||
|
|
||||||
if (subscriptionBroadcastReceiver != null && activity != null) {
|
|
||||||
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
@ -150,28 +134,61 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||||
|
|
||||||
activity.supportActionBar?.setDisplayShowTitleEnabled(true)
|
activity.supportActionBar?.setDisplayShowTitleEnabled(true)
|
||||||
activity.supportActionBar?.setTitle(R.string.tab_subscriptions)
|
activity.supportActionBar?.setTitle(R.string.tab_subscriptions)
|
||||||
|
|
||||||
|
buildImportExportMenu(menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupBroadcastReceiver() {
|
private fun buildImportExportMenu(menu: Menu) {
|
||||||
if (activity == null) return
|
// -- Import --
|
||||||
|
val importSubMenu = menu.addSubMenu(R.string.import_from)
|
||||||
|
|
||||||
if (subscriptionBroadcastReceiver != null) {
|
addMenuItemToSubmenu(importSubMenu, R.string.previous_export) { onImportPreviousSelected() }
|
||||||
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!)
|
.setIcon(R.drawable.ic_backup)
|
||||||
|
|
||||||
|
for (service in ServiceList.all()) {
|
||||||
|
val subscriptionExtractor = service.subscriptionExtractor ?: continue
|
||||||
|
|
||||||
|
val supportedSources = subscriptionExtractor.supportedSources
|
||||||
|
if (supportedSources.isEmpty()) continue
|
||||||
|
|
||||||
|
addMenuItemToSubmenu(importSubMenu, service.serviceInfo.name) {
|
||||||
|
onImportFromServiceSelected(service.serviceId)
|
||||||
|
}
|
||||||
|
.setIcon(ServiceHelper.getIcon(service.serviceId))
|
||||||
}
|
}
|
||||||
|
|
||||||
val filters = IntentFilter()
|
// -- Export --
|
||||||
filters.addAction(EXPORT_COMPLETE_ACTION)
|
val exportSubMenu = menu.addSubMenu(R.string.export_to)
|
||||||
filters.addAction(IMPORT_COMPLETE_ACTION)
|
|
||||||
subscriptionBroadcastReceiver = object : BroadcastReceiver() {
|
addMenuItemToSubmenu(exportSubMenu, R.string.file) { onExportSelected() }
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
.setIcon(R.drawable.ic_save)
|
||||||
_binding?.itemsList?.post {
|
|
||||||
importExportItem.isExpanded = false
|
|
||||||
importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver!!, filters)
|
private fun addMenuItemToSubmenu(
|
||||||
|
subMenu: SubMenu,
|
||||||
|
@StringRes title: Int,
|
||||||
|
onClick: Runnable
|
||||||
|
): MenuItem {
|
||||||
|
return setClickListenerToMenuItem(subMenu.add(title), onClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addMenuItemToSubmenu(
|
||||||
|
subMenu: SubMenu,
|
||||||
|
title: String,
|
||||||
|
onClick: Runnable
|
||||||
|
): MenuItem {
|
||||||
|
return setClickListenerToMenuItem(subMenu.add(title), onClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setClickListenerToMenuItem(
|
||||||
|
menuItem: MenuItem,
|
||||||
|
onClick: Runnable
|
||||||
|
): MenuItem {
|
||||||
|
menuItem.setOnMenuItemClickListener { _ ->
|
||||||
|
onClick.run()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
return menuItem
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onImportFromServiceSelected(serviceId: Int) {
|
private fun onImportFromServiceSelected(serviceId: Int) {
|
||||||
|
@ -263,13 +280,14 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||||
subscriptionsSection.setPlaceholder(EmptyPlaceholderItem())
|
subscriptionsSection.setPlaceholder(EmptyPlaceholderItem())
|
||||||
subscriptionsSection.setHideWhenEmpty(true)
|
subscriptionsSection.setHideWhenEmpty(true)
|
||||||
|
|
||||||
importExportItem = FeedImportExportItem(
|
groupAdapter.add(
|
||||||
{ onImportPreviousSelected() },
|
Section(
|
||||||
{ onImportFromServiceSelected(it) },
|
HeaderWithMenuItem(
|
||||||
{ onExportSelected() },
|
getString(R.string.tab_subscriptions)
|
||||||
importExportItemExpandedState ?: false
|
),
|
||||||
|
listOf(subscriptionsSection)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
groupAdapter.add(Section(importExportItem, listOf(subscriptionsSection)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun initViews(rootView: View, savedInstanceState: Bundle?) {
|
override fun initViews(rootView: View, savedInstanceState: Bundle?) {
|
||||||
|
@ -371,13 +389,6 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||||
subscriptionsSection.update(result.subscriptions)
|
subscriptionsSection.update(result.subscriptions)
|
||||||
subscriptionsSection.setHideWhenEmpty(false)
|
subscriptionsSection.setHideWhenEmpty(false)
|
||||||
|
|
||||||
if (result.subscriptions.isEmpty() && importExportItemExpandedState == null) {
|
|
||||||
binding.itemsList.post {
|
|
||||||
importExportItem.isExpanded = true
|
|
||||||
importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemsListState != null) {
|
if (itemsListState != null) {
|
||||||
binding.itemsList.layoutManager?.onRestoreInstanceState(itemsListState)
|
binding.itemsList.layoutManager?.onRestoreInstanceState(itemsListState)
|
||||||
itemsListState = null
|
itemsListState = null
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,11 @@
|
||||||
package org.schabi.newpipe.local.subscription;
|
package org.schabi.newpipe.local.subscription;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL;
|
||||||
|
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE;
|
||||||
|
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE;
|
||||||
|
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE;
|
||||||
|
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE;
|
||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
@ -40,12 +46,6 @@ import java.util.List;
|
||||||
|
|
||||||
import icepick.State;
|
import icepick.State;
|
||||||
|
|
||||||
import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL;
|
|
||||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE;
|
|
||||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE;
|
|
||||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE;
|
|
||||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE;
|
|
||||||
|
|
||||||
public class SubscriptionsImportFragment extends BaseFragment {
|
public class SubscriptionsImportFragment extends BaseFragment {
|
||||||
@State
|
@State
|
||||||
int currentServiceId = Constants.NO_SERVICE_ID;
|
int currentServiceId = Constants.NO_SERVICE_ID;
|
||||||
|
@ -89,7 +89,7 @@ public class SubscriptionsImportFragment extends BaseFragment {
|
||||||
if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) {
|
if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) {
|
||||||
ErrorUtil.showSnackbar(activity,
|
ErrorUtil.showSnackbar(activity,
|
||||||
new ErrorInfo(new String[]{}, UserAction.SUBSCRIPTION_IMPORT_EXPORT,
|
new ErrorInfo(new String[]{}, UserAction.SUBSCRIPTION_IMPORT_EXPORT,
|
||||||
NewPipe.getNameOfService(currentServiceId),
|
ServiceHelper.getNameOfServiceById(currentServiceId),
|
||||||
"Service does not support importing subscriptions",
|
"Service does not support importing subscriptions",
|
||||||
R.string.general_error));
|
R.string.general_error));
|
||||||
activity.finish();
|
activity.finish();
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package org.schabi.newpipe.local.subscription.dialog
|
package org.schabi.newpipe.local.subscription.dialog
|
||||||
|
|
||||||
import android.app.Dialog
|
import android.app.Dialog
|
||||||
import android.content.res.ColorStateList
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
@ -9,7 +8,7 @@ import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import androidx.core.os.bundleOf
|
import androidx.core.os.bundleOf
|
||||||
import androidx.core.view.isGone
|
import androidx.core.view.isGone
|
||||||
|
@ -127,7 +126,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
|
||||||
|
|
||||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP) {
|
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP) {
|
||||||
// KitKat doesn't apply container's theme to <include> content
|
// KitKat doesn't apply container's theme to <include> content
|
||||||
val contrastColor = ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.contrastColor))
|
val contrastColor = AppCompatResources.getColorStateList(requireContext(), R.color.contrastColor)
|
||||||
searchLayoutBinding.toolbarSearchEditText.setTextColor(contrastColor)
|
searchLayoutBinding.toolbarSearchEditText.setTextColor(contrastColor)
|
||||||
searchLayoutBinding.toolbarSearchEditText.setHintTextColor(contrastColor.withAlpha(128))
|
searchLayoutBinding.toolbarSearchEditText.setHintTextColor(contrastColor.withAlpha(128))
|
||||||
ImageViewCompat.setImageTintList(searchLayoutBinding.toolbarSearchClearIcon, contrastColor)
|
ImageViewCompat.setImageTintList(searchLayoutBinding.toolbarSearchClearIcon, contrastColor)
|
||||||
|
|
|
@ -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,
|
|
||||||
Observer {
|
|
||||||
when (it) {
|
when (it) {
|
||||||
ProcessingEvent -> disableInput()
|
ProcessingEvent -> disableInput()
|
||||||
SuccessEvent -> dismiss()
|
SuccessEvent -> dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
binding.feedGroupsList.layoutManager = LinearLayoutManager(requireContext())
|
binding.feedGroupsList.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.feedGroupsList.adapter = groupAdapter
|
binding.feedGroupsList.adapter = groupAdapter
|
||||||
|
|
|
@ -1,122 +0,0 @@
|
||||||
package org.schabi.newpipe.local.subscription.item
|
|
||||||
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.graphics.PorterDuff
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
import com.xwray.groupie.viewbinding.BindableItem
|
|
||||||
import com.xwray.groupie.viewbinding.GroupieViewHolder
|
|
||||||
import org.schabi.newpipe.R
|
|
||||||
import org.schabi.newpipe.databinding.FeedImportExportGroupBinding
|
|
||||||
import org.schabi.newpipe.extractor.NewPipe
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException
|
|
||||||
import org.schabi.newpipe.ktx.animateRotation
|
|
||||||
import org.schabi.newpipe.util.ServiceHelper
|
|
||||||
import org.schabi.newpipe.util.ThemeHelper
|
|
||||||
import org.schabi.newpipe.views.CollapsibleView
|
|
||||||
|
|
||||||
class FeedImportExportItem(
|
|
||||||
val onImportPreviousSelected: () -> Unit,
|
|
||||||
val onImportFromServiceSelected: (Int) -> Unit,
|
|
||||||
val onExportSelected: () -> Unit,
|
|
||||||
var isExpanded: Boolean = false
|
|
||||||
) : BindableItem<FeedImportExportGroupBinding>() {
|
|
||||||
companion object {
|
|
||||||
const val REFRESH_EXPANDED_STATUS = 123
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun bind(viewBinding: FeedImportExportGroupBinding, position: Int, payloads: MutableList<Any>) {
|
|
||||||
if (payloads.contains(REFRESH_EXPANDED_STATUS)) {
|
|
||||||
viewBinding.importExportOptions.apply { if (isExpanded) expand() else collapse() }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
super.bind(viewBinding, position, payloads)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getLayout(): Int = R.layout.feed_import_export_group
|
|
||||||
|
|
||||||
override fun bind(viewBinding: FeedImportExportGroupBinding, position: Int) {
|
|
||||||
if (viewBinding.importFromOptions.childCount == 0) setupImportFromItems(viewBinding.importFromOptions)
|
|
||||||
if (viewBinding.exportToOptions.childCount == 0) setupExportToItems(viewBinding.exportToOptions)
|
|
||||||
|
|
||||||
expandIconListener?.let { viewBinding.importExportOptions.removeListener(it) }
|
|
||||||
expandIconListener = CollapsibleView.StateListener { newState ->
|
|
||||||
viewBinding.importExportExpandIcon.animateRotation(
|
|
||||||
250, if (newState == CollapsibleView.COLLAPSED) 0 else 180
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
viewBinding.importExportOptions.currentState = if (isExpanded) CollapsibleView.EXPANDED else CollapsibleView.COLLAPSED
|
|
||||||
viewBinding.importExportExpandIcon.rotation = if (isExpanded) 180F else 0F
|
|
||||||
viewBinding.importExportOptions.ready()
|
|
||||||
|
|
||||||
viewBinding.importExportOptions.addListener(expandIconListener)
|
|
||||||
viewBinding.importExport.setOnClickListener {
|
|
||||||
viewBinding.importExportOptions.switchState()
|
|
||||||
isExpanded = viewBinding.importExportOptions.currentState == CollapsibleView.EXPANDED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun unbind(viewHolder: GroupieViewHolder<FeedImportExportGroupBinding>) {
|
|
||||||
super.unbind(viewHolder)
|
|
||||||
expandIconListener?.let { viewHolder.binding.importExportOptions.removeListener(it) }
|
|
||||||
expandIconListener = null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun initializeViewBinding(view: View) = FeedImportExportGroupBinding.bind(view)
|
|
||||||
|
|
||||||
private var expandIconListener: CollapsibleView.StateListener? = null
|
|
||||||
|
|
||||||
private fun addItemView(title: String, @DrawableRes icon: Int, container: ViewGroup): View {
|
|
||||||
val itemRoot = View.inflate(container.context, R.layout.subscription_import_export_item, null)
|
|
||||||
val titleView = itemRoot.findViewById<TextView>(android.R.id.text1)
|
|
||||||
val iconView = itemRoot.findViewById<ImageView>(android.R.id.icon1)
|
|
||||||
|
|
||||||
titleView.text = title
|
|
||||||
iconView.setImageResource(icon)
|
|
||||||
|
|
||||||
container.addView(itemRoot)
|
|
||||||
return itemRoot
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupImportFromItems(listHolder: ViewGroup) {
|
|
||||||
val previousBackupItem = addItemView(
|
|
||||||
listHolder.context.getString(R.string.previous_export),
|
|
||||||
R.drawable.ic_backup, listHolder
|
|
||||||
)
|
|
||||||
previousBackupItem.setOnClickListener { onImportPreviousSelected() }
|
|
||||||
|
|
||||||
val iconColor = if (ThemeHelper.isLightThemeSelected(listHolder.context)) Color.BLACK else Color.WHITE
|
|
||||||
val services = listHolder.context.resources.getStringArray(R.array.service_list)
|
|
||||||
for (serviceName in services) {
|
|
||||||
try {
|
|
||||||
val service = NewPipe.getService(serviceName)
|
|
||||||
|
|
||||||
val subscriptionExtractor = service.subscriptionExtractor ?: continue
|
|
||||||
|
|
||||||
val supportedSources = subscriptionExtractor.supportedSources
|
|
||||||
if (supportedSources.isEmpty()) continue
|
|
||||||
|
|
||||||
val itemView = addItemView(serviceName, ServiceHelper.getIcon(service.serviceId), listHolder)
|
|
||||||
val iconView = itemView.findViewById<ImageView>(android.R.id.icon1)
|
|
||||||
iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN)
|
|
||||||
|
|
||||||
itemView.setOnClickListener { onImportFromServiceSelected(service.serviceId) }
|
|
||||||
} catch (e: ExtractionException) {
|
|
||||||
throw RuntimeException("Services array contains an entry that it's not a valid service name ($serviceName)", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setupExportToItems(listHolder: ViewGroup) {
|
|
||||||
val previousBackupItem = addItemView(
|
|
||||||
listHolder.context.getString(R.string.file),
|
|
||||||
R.drawable.ic_save, listHolder
|
|
||||||
)
|
|
||||||
previousBackupItem.setOnClickListener { onExportSelected() }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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;
|
||||||
|
|
||||||
|
@ -325,6 +324,9 @@ public class LocalPlayer implements EventListener {
|
||||||
case "preview":
|
case "preview":
|
||||||
toastText = context
|
toastText = context
|
||||||
.getString(R.string.sponsor_block_skip_preview_toast);
|
.getString(R.string.sponsor_block_skip_preview_toast);
|
||||||
|
case "filler":
|
||||||
|
toastText = context
|
||||||
|
.getString(R.string.sponsor_block_skip_filler_toast);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
@ -107,7 +102,10 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||||
getMenuInflater().inflate(R.menu.menu_play_queue, m);
|
getMenuInflater().inflate(R.menu.menu_play_queue, m);
|
||||||
getMenuInflater().inflate(R.menu.menu_play_queue_bg, m);
|
getMenuInflater().inflate(R.menu.menu_play_queue_bg, m);
|
||||||
onMaybeMuteChanged();
|
onMaybeMuteChanged();
|
||||||
|
// to avoid null reference
|
||||||
|
if (player != null) {
|
||||||
onPlaybackParameterChanged(player.getPlaybackParameters());
|
onPlaybackParameterChanged(player.getPlaybackParameters());
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -133,7 +131,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 +451,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 +614,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
|
@ -0,0 +1,136 @@
|
||||||
|
package org.schabi.newpipe.player.datasource;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import com.google.android.exoplayer2.C;
|
||||||
|
import com.google.android.exoplayer2.source.hls.HlsDataSourceFactory;
|
||||||
|
import com.google.android.exoplayer2.upstream.ByteArrayDataSource;
|
||||||
|
import com.google.android.exoplayer2.upstream.DataSource;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A {@link HlsDataSourceFactory} which allows playback of non-URI media HLS playlists for
|
||||||
|
* {@link com.google.android.exoplayer2.source.hls.HlsMediaSource HlsMediaSource}s.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* If media requests are relative, the URI from which the manifest comes from (either the
|
||||||
|
* manifest URI (preferred) or the master URI (if applicable)) must be returned, otherwise the
|
||||||
|
* content will be not playable, as it will be an invalid URL, or it may be treat as something
|
||||||
|
* unexpected, for instance as a file for
|
||||||
|
* {@link com.google.android.exoplayer2.upstream.DefaultDataSource DefaultDataSource}s.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* See {@link #createDataSource(int)} for changes and implementation details.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public final class NonUriHlsDataSourceFactory implements HlsDataSourceFactory {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder class of {@link NonUriHlsDataSourceFactory} instances.
|
||||||
|
*/
|
||||||
|
public static final class Builder {
|
||||||
|
private DataSource.Factory dataSourceFactory;
|
||||||
|
private String playlistString;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the {@link DataSource.Factory} which will be used to create non manifest contents
|
||||||
|
* {@link DataSource}s.
|
||||||
|
*
|
||||||
|
* @param dataSourceFactoryForNonManifestContents the {@link DataSource.Factory} which will
|
||||||
|
* be used to create non manifest contents
|
||||||
|
* {@link DataSource}s, which cannot be null
|
||||||
|
*/
|
||||||
|
public void setDataSourceFactory(
|
||||||
|
@NonNull final DataSource.Factory dataSourceFactoryForNonManifestContents) {
|
||||||
|
this.dataSourceFactory = dataSourceFactoryForNonManifestContents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the HLS playlist which will be used for manifests requests.
|
||||||
|
*
|
||||||
|
* @param hlsPlaylistString the string which correspond to the response of the HLS
|
||||||
|
* manifest, which cannot be null or empty
|
||||||
|
*/
|
||||||
|
public void setPlaylistString(@NonNull final String hlsPlaylistString) {
|
||||||
|
this.playlistString = hlsPlaylistString;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new {@link NonUriHlsDataSourceFactory} with the given data source factory and
|
||||||
|
* the given HLS playlist.
|
||||||
|
*
|
||||||
|
* @return a {@link NonUriHlsDataSourceFactory}
|
||||||
|
* @throws IllegalArgumentException if the data source factory is null or if the HLS
|
||||||
|
* playlist string set is null or empty
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public NonUriHlsDataSourceFactory build() {
|
||||||
|
if (dataSourceFactory == null) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"No DataSource.Factory valid instance has been specified.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNullOrEmpty(playlistString)) {
|
||||||
|
throw new IllegalArgumentException("No HLS valid playlist has been specified.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NonUriHlsDataSourceFactory(dataSourceFactory,
|
||||||
|
playlistString.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final DataSource.Factory dataSourceFactory;
|
||||||
|
private final byte[] playlistStringByteArray;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a {@link NonUriHlsDataSourceFactory} instance.
|
||||||
|
*
|
||||||
|
* @param dataSourceFactory the {@link DataSource.Factory} which will be used to build
|
||||||
|
* non manifests {@link DataSource}s, which must not be null
|
||||||
|
* @param playlistStringByteArray a byte array of the HLS playlist, which must not be null
|
||||||
|
*/
|
||||||
|
private NonUriHlsDataSourceFactory(@NonNull final DataSource.Factory dataSourceFactory,
|
||||||
|
@NonNull final byte[] playlistStringByteArray) {
|
||||||
|
this.dataSourceFactory = dataSourceFactory;
|
||||||
|
this.playlistStringByteArray = playlistStringByteArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a {@link DataSource} for the given data type.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Contrary to {@link com.google.android.exoplayer2.source.hls.DefaultHlsDataSourceFactory
|
||||||
|
* ExoPlayer's default implementation}, this implementation is not always using the
|
||||||
|
* {@link DataSource.Factory} passed to the
|
||||||
|
* {@link com.google.android.exoplayer2.source.hls.HlsMediaSource.Factory
|
||||||
|
* HlsMediaSource.Factory} constructor, only when it's not
|
||||||
|
* {@link C#DATA_TYPE_MANIFEST the manifest type}.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This change allow playback of non-URI HLS contents, when the manifest is not a master
|
||||||
|
* manifest/playlist (otherwise, endless loops should be encountered because the
|
||||||
|
* {@link DataSource}s created for media playlists should use the master playlist response
|
||||||
|
* instead).
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param dataType the data type for which the {@link DataSource} will be used, which is one of
|
||||||
|
* {@link C} {@code .DATA_TYPE_*} constants
|
||||||
|
* @return a {@link DataSource} for the given data type
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public DataSource createDataSource(final int dataType) {
|
||||||
|
// The manifest is already downloaded and provided with playlistStringByteArray, so we
|
||||||
|
// don't need to download it again and we can use a ByteArrayDataSource instead
|
||||||
|
if (dataType == C.DATA_TYPE_MANIFEST) {
|
||||||
|
return new ByteArrayDataSource(playlistStringByteArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataSourceFactory.createDataSource();
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load diff
|
@ -126,6 +126,14 @@ public class PlayerGestureListener
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onScrollMainVolume(final float distanceX, final float distanceY) {
|
private void onScrollMainVolume(final float distanceX, final float distanceY) {
|
||||||
|
// If we just started sliding, change the progress bar to match the system volume
|
||||||
|
if (player.getVolumeRelativeLayout().getVisibility() != View.VISIBLE) {
|
||||||
|
final float volumePercent = player
|
||||||
|
.getAudioReactor().getVolume() / (float) maxVolume;
|
||||||
|
player.getVolumeProgressBar().setProgress(
|
||||||
|
(int) (volumePercent * player.getMaxGestureLength()));
|
||||||
|
}
|
||||||
|
|
||||||
player.getVolumeProgressBar().incrementProgressBy((int) distanceY);
|
player.getVolumeProgressBar().incrementProgressBy((int) distanceY);
|
||||||
final float currentProgressPercent = (float) player
|
final float currentProgressPercent = (float) player
|
||||||
.getVolumeProgressBar().getProgress() / player.getMaxGestureLength();
|
.getVolumeProgressBar().getProgress() / player.getMaxGestureLength();
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -1,93 +1,46 @@
|
||||||
package org.schabi.newpipe.player.helper;
|
package org.schabi.newpipe.player.helper;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
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.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;
|
||||||
import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
|
import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
|
||||||
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor;
|
|
||||||
import com.google.android.exoplayer2.upstream.cache.SimpleCache;
|
import com.google.android.exoplayer2.upstream.cache.SimpleCache;
|
||||||
|
|
||||||
import java.io.File;
|
final class CacheFactory implements DataSource.Factory {
|
||||||
|
private static final int CACHE_FLAGS = CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR;
|
||||||
|
|
||||||
/* package-private */ class CacheFactory implements DataSource.Factory {
|
private final Context context;
|
||||||
private static final String TAG = "CacheFactory";
|
private final TransferListener transferListener;
|
||||||
|
private final DataSource.Factory upstreamDataSourceFactory;
|
||||||
|
private final SimpleCache cache;
|
||||||
|
|
||||||
private static final String CACHE_FOLDER_NAME = "exoplayer";
|
CacheFactory(final Context context,
|
||||||
private static final int CACHE_FLAGS = CacheDataSource.FLAG_BLOCK_ON_CACHE
|
final TransferListener transferListener,
|
||||||
| CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR;
|
final SimpleCache cache,
|
||||||
|
final DataSource.Factory upstreamDataSourceFactory) {
|
||||||
private final DefaultDataSourceFactory dataSourceFactory;
|
this.context = context;
|
||||||
private final File cacheDir;
|
this.transferListener = transferListener;
|
||||||
private final long maxFileSize;
|
this.cache = cache;
|
||||||
|
this.upstreamDataSourceFactory = upstreamDataSourceFactory;
|
||||||
// Creating cache on every instance may cause problems with multiple players when
|
|
||||||
// sources are not ExtractorMediaSource
|
|
||||||
// see: https://stackoverflow.com/questions/28700391/using-cache-in-exoplayer
|
|
||||||
// todo: make this a singleton?
|
|
||||||
private static SimpleCache cache;
|
|
||||||
|
|
||||||
CacheFactory(@NonNull final Context context,
|
|
||||||
@NonNull final String userAgent,
|
|
||||||
@NonNull final TransferListener transferListener) {
|
|
||||||
this(context, userAgent, transferListener, PlayerHelper.getPreferredCacheSize(),
|
|
||||||
PlayerHelper.getPreferredFileSize());
|
|
||||||
}
|
|
||||||
|
|
||||||
private CacheFactory(@NonNull final Context context,
|
|
||||||
@NonNull final String userAgent,
|
|
||||||
@NonNull final TransferListener transferListener,
|
|
||||||
final long maxCacheSize,
|
|
||||||
final long maxFileSize) {
|
|
||||||
this.maxFileSize = maxFileSize;
|
|
||||||
|
|
||||||
dataSourceFactory = new DefaultDataSourceFactory(context, userAgent, transferListener);
|
|
||||||
cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME);
|
|
||||||
if (!cacheDir.exists()) {
|
|
||||||
//noinspection ResultOfMethodCallIgnored
|
|
||||||
cacheDir.mkdir();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cache == null) {
|
|
||||||
final LeastRecentlyUsedCacheEvictor evictor
|
|
||||||
= new LeastRecentlyUsedCacheEvictor(maxCacheSize);
|
|
||||||
cache = new SimpleCache(cacheDir, evictor, new ExoDatabaseProvider(context));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
@Override
|
@Override
|
||||||
public DataSource createDataSource() {
|
public DataSource createDataSource() {
|
||||||
Log.d(TAG, "initExoPlayerCache: cacheDir = " + cacheDir.getAbsolutePath());
|
final DefaultDataSource dataSource = new DefaultDataSource.Factory(context,
|
||||||
|
upstreamDataSourceFactory)
|
||||||
|
.setTransferListener(transferListener)
|
||||||
|
.createDataSource();
|
||||||
|
|
||||||
final DefaultDataSource dataSource = dataSourceFactory.createDataSource();
|
|
||||||
final FileDataSource fileSource = new FileDataSource();
|
final FileDataSource fileSource = new FileDataSource();
|
||||||
final CacheDataSink dataSink = new CacheDataSink(cache, maxFileSize);
|
final CacheDataSink dataSink
|
||||||
|
= new CacheDataSink(cache, PlayerHelper.getPreferredFileSize());
|
||||||
return new CacheDataSource(cache, dataSource, fileSource, dataSink, CACHE_FLAGS, null);
|
return new CacheDataSource(cache, dataSource, fileSource, dataSink, CACHE_FLAGS, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void tryDeleteCacheFiles() {
|
|
||||||
if (!cacheDir.exists() || !cacheDir.isDirectory()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (final File file : cacheDir.listFiles()) {
|
|
||||||
final String filePath = file.getAbsolutePath();
|
|
||||||
final boolean deleteSuccessful = file.delete();
|
|
||||||
|
|
||||||
Log.d(TAG, "tryDeleteCacheFiles: " + filePath + " deleted = " + deleteSuccessful);
|
|
||||||
}
|
|
||||||
} catch (final Exception ignored) {
|
|
||||||
Log.e(TAG, "Failed to delete file.", ignored);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue