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 -> Tools -> Checkstyle`.
|
||||
- Add NewPipe's configuration file by clicking the `+` in the right toolbar of the "Configuration File" list.
|
||||
- Under the "Use a local Checkstyle file" bullet, click on `Browse` and pick the file named `checkstyle.xml` in the project's root folder.
|
||||
- Under the "Use a local Checkstyle file" bullet, click on `Browse` and, enter `checkstyle` folder under the project's root path and pick the file named `checkstyle.xml`.
|
||||
- Enable "Store relative to project location" so that moving the directory around does not create issues.
|
||||
- Insert a description in the top bar, then click `Next` and then `Finish`.
|
||||
- Activate the configuration file you just added by enabling the checkbox on the left.
|
||||
|
|
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
|
@ -6,6 +6,7 @@ on:
|
|||
branches:
|
||||
- dev
|
||||
- master
|
||||
- release/**
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- 'doc/**'
|
||||
|
@ -31,7 +32,7 @@ jobs:
|
|||
build-and-test-jvm:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- uses: gradle/wrapper-validation-action@v1
|
||||
|
||||
- name: create and checkout branch
|
||||
|
@ -40,7 +41,7 @@ jobs:
|
|||
run: git checkout -B ${{ github.head_ref }}
|
||||
|
||||
- name: set up JDK 11
|
||||
uses: actions/setup-java@v2
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 11
|
||||
distribution: "temurin"
|
||||
|
@ -50,7 +51,7 @@ jobs:
|
|||
run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: app
|
||||
path: app/build/outputs/apk/debug/*.apk
|
||||
|
@ -64,10 +65,10 @@ jobs:
|
|||
# api-level 19 is min sdk, but throws errors related to desugaring
|
||||
api-level: [ 21, 29 ]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: set up JDK 11
|
||||
uses: actions/setup-java@v2
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 11
|
||||
distribution: "temurin"
|
||||
|
@ -82,7 +83,7 @@ jobs:
|
|||
script: ./gradlew connectedCheck --stacktrace
|
||||
|
||||
- name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v3
|
||||
if: failure()
|
||||
with:
|
||||
name: android-test-report-api${{ matrix.api-level }}
|
||||
|
@ -91,19 +92,19 @@ jobs:
|
|||
sonar:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: Set up JDK 11
|
||||
uses: actions/setup-java@v2
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 11 # Sonar requires JDK 11
|
||||
distribution: "temurin"
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Cache SonarCloud packages
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.sonar/cache
|
||||
key: ${{ runner.os }}-sonar
|
||||
|
|
6
.github/workflows/image-minimizer.yml
vendored
6
.github/workflows/image-minimizer.yml
vendored
|
@ -11,9 +11,9 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
|
@ -21,7 +21,7 @@ jobs:
|
|||
run: npm i probe-image-size@7.2.3 --ignore-scripts
|
||||
|
||||
- name: Minimize simple images
|
||||
uses: actions/github-script@v5
|
||||
uses: actions/github-script@v6
|
||||
timeout-minutes: 3
|
||||
with:
|
||||
script: |
|
||||
|
|
8
LICENSE
8
LICENSE
|
@ -1,7 +1,7 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
@ -645,7 +645,7 @@ the "copyright" line and a pointer to where the full notice is found.
|
|||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
|
@ -664,11 +664,11 @@ might be different; for a GUI interface, you would use an "about box".
|
|||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# NewPipe x SponsorBlock
|
||||
A fork of [NewPipe](https://github.com/TeamNewPipe/NewPipe) with [SponsorBlock](https://sponsor.ajay.app/) functionality.
|
||||
# NewPipe x SponsorBlock x Return YouTube Dislike
|
||||
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)
|
||||
![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.
|
||||
|
||||
## 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
|
||||
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 {
|
||||
compileSdk 31
|
||||
buildToolsVersion '30.0.3'
|
||||
buildToolsVersion '31.0.0'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "org.polymorphicshade.newpipe"
|
||||
resValue "string", "app_name", "NewPipe SponsorBlock"
|
||||
minSdk 19
|
||||
targetSdk 29
|
||||
versionCode 984
|
||||
versionName "0.22.1"
|
||||
versionCode 987
|
||||
versionName "0.23.1"
|
||||
|
||||
multiDexEnabled true
|
||||
|
||||
|
@ -98,15 +98,16 @@ android {
|
|||
}
|
||||
|
||||
ext {
|
||||
checkstyleVersion = '9.2.1'
|
||||
checkstyleVersion = '10.0'
|
||||
|
||||
androidxLifecycleVersion = '2.3.1'
|
||||
androidxRoomVersion = '2.3.0'
|
||||
androidxRoomVersion = '2.4.2'
|
||||
androidxWorkVersion = '2.7.1'
|
||||
|
||||
icepickVersion = '3.2.0'
|
||||
exoPlayerVersion = '2.14.2'
|
||||
exoPlayerVersion = '2.17.1'
|
||||
googleAutoServiceVersion = '1.0.1'
|
||||
groupieVersion = '2.10.0'
|
||||
groupieVersion = '2.10.1'
|
||||
markwonVersion = '4.6.2'
|
||||
|
||||
leakCanaryVersion = '2.5'
|
||||
|
@ -121,7 +122,7 @@ configurations {
|
|||
}
|
||||
|
||||
checkstyle {
|
||||
getConfigDirectory().set(rootProject.file("."))
|
||||
getConfigDirectory().set(rootProject.file("checkstyle"))
|
||||
ignoreFailures false
|
||||
showViolations true
|
||||
toolVersion = checkstyleVersion
|
||||
|
@ -189,11 +190,11 @@ dependencies {
|
|||
// name and the commit hash with the commit hash of the (pushed) commit you want to test
|
||||
// This works thanks to JitPack: https://jitpack.io/
|
||||
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
|
||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.21.14'
|
||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:5219a705bab539cf8c6624d0cec216e76e85f0b1'
|
||||
|
||||
/** Checkstyle **/
|
||||
checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}"
|
||||
ktlint 'com.pinterest:ktlint:0.43.2'
|
||||
ktlint 'com.pinterest:ktlint:0.44.0'
|
||||
|
||||
/** Kotlin **/
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}"
|
||||
|
@ -201,16 +202,16 @@ dependencies {
|
|||
/** AndroidX **/
|
||||
implementation 'androidx.appcompat:appcompat:1.3.1'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
|
||||
implementation 'androidx.core:core-ktx:1.6.0'
|
||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.3.6'
|
||||
implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}"
|
||||
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0'
|
||||
implementation 'androidx.media:media:1.4.3'
|
||||
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
|
||||
implementation 'androidx.media:media:1.5.0'
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
implementation 'androidx.preference:preference:1.1.1'
|
||||
implementation 'androidx.preference:preference:1.2.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
implementation "androidx.room:room-runtime:${androidxRoomVersion}"
|
||||
implementation "androidx.room:room-rxjava3:${androidxRoomVersion}"
|
||||
|
@ -220,7 +221,9 @@ dependencies {
|
|||
// https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
||||
implementation 'androidx.webkit:webkit:1.4.0'
|
||||
implementation 'com.google.android.material:material:1.4.0'
|
||||
implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}"
|
||||
implementation "androidx.work:work-rxjava3:${androidxWorkVersion}"
|
||||
implementation 'com.google.android.material:material:1.5.0'
|
||||
|
||||
/** Third-party libraries **/
|
||||
// Instance state boilerplate elimination
|
||||
|
@ -246,8 +249,6 @@ dependencies {
|
|||
implementation "com.github.lisawray.groupie:groupie:${groupieVersion}"
|
||||
implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}"
|
||||
|
||||
// Circular ImageView
|
||||
implementation "de.hdodenhof:circleimageview:3.1.0"
|
||||
// Image loading
|
||||
//noinspection GradleDependency --> 2.8 is the last version, not 2.71828!
|
||||
implementation "com.squareup.picasso:picasso:2.8"
|
||||
|
@ -260,7 +261,7 @@ dependencies {
|
|||
implementation "com.nononsenseapps:filepicker:4.2.1"
|
||||
|
||||
// Crash reporting
|
||||
implementation "ch.acra:acra-core:5.8.4"
|
||||
implementation "ch.acra:acra-core:5.9.3"
|
||||
|
||||
// Properly restarting
|
||||
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 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
|
||||
android:name=".RouterActivity$FetcherService"
|
||||
android:exported="false" />
|
||||
<service
|
||||
android:name=".CheckForNewAppVersion"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- opting out of sending metrics to Google in Android System WebView -->
|
||||
<meta-data android:name="android.webkit.WebView.MetricsOptOut" android:value="true" />
|
||||
|
|
|
@ -27,7 +27,7 @@ import org.schabi.newpipe.util.StateSaver;
|
|||
import java.io.IOException;
|
||||
import java.io.InterruptedIOException;
|
||||
import java.net.SocketException;
|
||||
import java.util.Arrays;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
|
@ -205,7 +205,7 @@ public class App extends MultiDexApplication {
|
|||
return;
|
||||
}
|
||||
|
||||
final CoreConfigurationBuilder acraConfig = new CoreConfigurationBuilder(this)
|
||||
final CoreConfigurationBuilder acraConfig = new CoreConfigurationBuilder()
|
||||
.withBuildConfigClass(BuildConfig.class);
|
||||
ACRA.init(this, acraConfig);
|
||||
}
|
||||
|
@ -213,37 +213,44 @@ public class App extends MultiDexApplication {
|
|||
private void initNotificationChannels() {
|
||||
// Keep the importance below DEFAULT to avoid making noise on every notification update for
|
||||
// the main and update channels
|
||||
final NotificationChannelCompat mainChannel = new NotificationChannelCompat
|
||||
final List<NotificationChannelCompat> notificationChannelCompats = new ArrayList<>();
|
||||
notificationChannelCompats.add(new NotificationChannelCompat
|
||||
.Builder(getString(R.string.notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.notification_channel_name))
|
||||
.setDescription(getString(R.string.notification_channel_description))
|
||||
.build();
|
||||
.build());
|
||||
|
||||
final NotificationChannelCompat appUpdateChannel = new NotificationChannelCompat
|
||||
notificationChannelCompats.add(new NotificationChannelCompat
|
||||
.Builder(getString(R.string.app_update_notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.app_update_notification_channel_name))
|
||||
.setDescription(getString(R.string.app_update_notification_channel_description))
|
||||
.build();
|
||||
.build());
|
||||
|
||||
final NotificationChannelCompat hashChannel = new NotificationChannelCompat
|
||||
notificationChannelCompats.add(new NotificationChannelCompat
|
||||
.Builder(getString(R.string.hash_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||
.setName(getString(R.string.hash_channel_name))
|
||||
.setDescription(getString(R.string.hash_channel_description))
|
||||
.build();
|
||||
.build());
|
||||
|
||||
final NotificationChannelCompat errorReportChannel = new NotificationChannelCompat
|
||||
notificationChannelCompats.add(new NotificationChannelCompat
|
||||
.Builder(getString(R.string.error_report_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.error_report_channel_name))
|
||||
.setDescription(getString(R.string.error_report_channel_description))
|
||||
.build();
|
||||
.build());
|
||||
|
||||
notificationChannelCompats.add(new NotificationChannelCompat
|
||||
.Builder(getString(R.string.streams_notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
||||
.setName(getString(R.string.streams_notification_channel_name))
|
||||
.setDescription(getString(R.string.streams_notification_channel_description))
|
||||
.build());
|
||||
|
||||
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
|
||||
notificationManager.createNotificationChannelsCompat(Arrays.asList(mainChannel,
|
||||
appUpdateChannel, hashChannel, errorReportChannel));
|
||||
notificationManager.createNotificationChannelsCompat(notificationChannelCompats);
|
||||
}
|
||||
|
||||
protected boolean isDisposedRxExceptionsReported() {
|
||||
|
|
|
@ -1,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 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
|
||||
= "youtube_restricted_mode_key";
|
||||
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.List;
|
||||
|
||||
public class LocalPlayerActivity extends AppCompatActivity implements Player.EventListener,
|
||||
public class LocalPlayerActivity extends AppCompatActivity implements Player.Listener,
|
||||
LocalPlayerListener, PlaybackParameterDialog.Callback {
|
||||
private LocalPlayer localPlayer;
|
||||
private PlayerView playerView;
|
||||
|
|
|
@ -20,7 +20,6 @@
|
|||
|
||||
package org.schabi.newpipe;
|
||||
|
||||
import static org.schabi.newpipe.CheckForNewAppVersion.checkNewVersion;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
|
@ -72,6 +71,7 @@ import org.schabi.newpipe.fragments.BackPressable;
|
|||
import org.schabi.newpipe.fragments.MainFragment;
|
||||
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
|
||||
import org.schabi.newpipe.fragments.list.search.SearchFragment;
|
||||
import org.schabi.newpipe.local.feed.notifications.NotificationWorker;
|
||||
import org.schabi.newpipe.player.Player;
|
||||
import org.schabi.newpipe.player.event.OnKeyDownListener;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
|
@ -159,20 +159,28 @@ public class MainActivity extends AppCompatActivity {
|
|||
} catch (final Exception e) {
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Setting up drawer", e);
|
||||
}
|
||||
|
||||
if (DeviceUtils.isTv(this)) {
|
||||
FocusOverlayView.setupFocusObserver(this);
|
||||
}
|
||||
openMiniPlayerUponPlayerStarted();
|
||||
|
||||
// Schedule worker for checking for new streams and creating corresponding notifications
|
||||
// if this is enabled by the user.
|
||||
NotificationWorker.initialize(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostCreate(final Bundle savedInstanceState) {
|
||||
super.onPostCreate(savedInstanceState);
|
||||
// Start the service which is checking all conditions
|
||||
// and eventually searching for a new version.
|
||||
// The service searching for a new NewPipe version must not be started in background.
|
||||
checkNewVersion();
|
||||
|
||||
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.
|
||||
NewVersionWorker.enqueueNewVersionCheckingWork(app);
|
||||
}
|
||||
}
|
||||
|
||||
private void setupDrawer() throws ExtractionException {
|
||||
|
@ -221,7 +229,7 @@ public class MainActivity extends AppCompatActivity {
|
|||
drawerLayoutBinding.navigation.getMenu()
|
||||
.add(R.id.menu_tabs_group, kioskId, 0, KioskTranslator
|
||||
.getTranslatedKioskName(ks, this))
|
||||
.setIcon(KioskTranslator.getKioskIcon(ks, this));
|
||||
.setIcon(KioskTranslator.getKioskIcon(ks));
|
||||
kioskId++;
|
||||
}
|
||||
|
||||
|
@ -713,7 +721,7 @@ public class MainActivity extends AppCompatActivity {
|
|||
if (toggle != null) {
|
||||
toggle.syncState();
|
||||
toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> mainBinding.getRoot()
|
||||
.openDrawer(GravityCompat.START));
|
||||
.open());
|
||||
mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED);
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
package org.schabi.newpipe;
|
||||
|
||||
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
|
||||
|
@ -8,11 +14,6 @@ import androidx.room.Room;
|
|||
|
||||
import org.schabi.newpipe.database.AppDatabase;
|
||||
|
||||
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
|
||||
|
||||
public final class NewPipeDatabase {
|
||||
private static volatile AppDatabase databaseInstance;
|
||||
|
||||
|
@ -23,7 +24,7 @@ public final class NewPipeDatabase {
|
|||
private static AppDatabase getDatabase(final Context context) {
|
||||
return Room
|
||||
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
package org.schabi.newpipe
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class NewVersionManager {
|
||||
|
||||
fun isExpired(expiry: Long): Boolean {
|
||||
return Instant.ofEpochSecond(expiry).isBefore(Instant.now())
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce expiry date time in between 6 hours and 72 hours from now
|
||||
*
|
||||
* @return Epoch second of expiry date time
|
||||
*/
|
||||
fun coerceExpiry(expiryString: String?): Long {
|
||||
val now = ZonedDateTime.now()
|
||||
return expiryString?.let {
|
||||
|
||||
var expiry = ZonedDateTime.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(expiryString))
|
||||
expiry = maxOf(expiry, now.plusHours(6))
|
||||
expiry = minOf(expiry, now.plusHours(72))
|
||||
expiry.toEpochSecond()
|
||||
} ?: now.plusHours(6).toEpochSecond()
|
||||
}
|
||||
}
|
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.PlayQueueItem;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.SaveUploaderUrlHelper;
|
||||
import org.schabi.newpipe.util.SparseItemUtil;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
|
@ -62,7 +62,8 @@ public final class QueueItemMenuUtil {
|
|||
|
||||
return true;
|
||||
case R.id.menu_item_channel_details:
|
||||
SaveUploaderUrlHelper.saveUploaderUrlIfNeeded(context, item,
|
||||
SparseItemUtil.fetchUploaderUrlIfSparse(context, item.getServiceId(),
|
||||
item.getUrl(), item.getUploaderUrl(),
|
||||
// An intent must be used here.
|
||||
// Opening with FragmentManager transactions is not working,
|
||||
// as PlayQueueActivity doesn't use fragments.
|
||||
|
|
|
@ -24,12 +24,12 @@ import android.widget.Toast;
|
|||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.ServiceCompat;
|
||||
import androidx.core.widget.TextViewCompat;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
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.playlist.PlaylistInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.extractor.stream.VideoStream;
|
||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||
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.DeviceUtils;
|
||||
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.PermissionHelper;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
@ -127,8 +126,10 @@ public class RouterActivity extends AppCompatActivity {
|
|||
}
|
||||
}
|
||||
|
||||
ThemeHelper.setDayNightMode(this);
|
||||
setTheme(ThemeHelper.isLightThemeSelected(this)
|
||||
? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark);
|
||||
Localization.assureCorrectAppLanguage(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -257,80 +258,122 @@ public class RouterActivity extends AppCompatActivity {
|
|||
protected void onSuccess() {
|
||||
final SharedPreferences preferences = PreferenceManager
|
||||
.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 String videoPlayerKey = getString(R.string.video_player_key);
|
||||
final String backgroundPlayerKey = getString(R.string.background_player_key);
|
||||
final String popupPlayerKey = getString(R.string.popup_player_key);
|
||||
final String downloadKey = getString(R.string.download_key);
|
||||
final String alwaysAskKey = getString(R.string.always_ask_open_action_key);
|
||||
final ChoiceAvailabilityChecker choiceChecker = new ChoiceAvailabilityChecker(
|
||||
getChoicesForService(currentService, currentLinkType),
|
||||
preferences.getString(getString(R.string.preferred_open_action_key),
|
||||
getString(R.string.preferred_open_action_default)));
|
||||
|
||||
if (selectedChoiceKey.equals(alwaysAskKey)) {
|
||||
final List<AdapterChoiceItem> choices
|
||||
= getChoicesForService(currentService, currentLinkType);
|
||||
// Check for non-player related choices
|
||||
if (choiceChecker.isAvailableAndSelected(
|
||||
R.string.show_info_key,
|
||||
R.string.download_key,
|
||||
R.string.add_to_playlist_key)) {
|
||||
handleChoice(choiceChecker.getSelectedChoiceKey());
|
||||
return;
|
||||
}
|
||||
// Check if the choice is player related
|
||||
if (choiceChecker.isAvailableAndSelected(
|
||||
R.string.video_player_key,
|
||||
R.string.background_player_key,
|
||||
R.string.popup_player_key)) {
|
||||
|
||||
final String selectedChoice = choiceChecker.getSelectedChoiceKey();
|
||||
|
||||
switch (choices.size()) {
|
||||
case 1:
|
||||
handleChoice(choices.get(0).key);
|
||||
break;
|
||||
case 0:
|
||||
handleChoice(showInfoKey);
|
||||
break;
|
||||
default:
|
||||
showDialog(choices);
|
||||
break;
|
||||
}
|
||||
} else if (selectedChoiceKey.equals(showInfoKey)) {
|
||||
handleChoice(showInfoKey);
|
||||
} else if (selectedChoiceKey.equals(downloadKey)) {
|
||||
handleChoice(downloadKey);
|
||||
} else {
|
||||
final boolean isExtVideoEnabled = preferences.getBoolean(
|
||||
getString(R.string.use_external_video_player_key), false);
|
||||
final boolean isExtAudioEnabled = preferences.getBoolean(
|
||||
getString(R.string.use_external_audio_player_key), false);
|
||||
final boolean isVideoPlayerSelected = selectedChoiceKey.equals(videoPlayerKey)
|
||||
|| selectedChoiceKey.equals(popupPlayerKey);
|
||||
final boolean isAudioPlayerSelected = selectedChoiceKey.equals(backgroundPlayerKey);
|
||||
final boolean isVideoPlayerSelected =
|
||||
selectedChoice.equals(getString(R.string.video_player_key))
|
||||
|| selectedChoice.equals(getString(R.string.popup_player_key));
|
||||
final boolean isAudioPlayerSelected =
|
||||
selectedChoice.equals(getString(R.string.background_player_key));
|
||||
|
||||
if (currentLinkType != LinkType.STREAM) {
|
||||
if (isExtAudioEnabled && isAudioPlayerSelected
|
||||
|| isExtVideoEnabled && isVideoPlayerSelected) {
|
||||
Toast.makeText(this, R.string.external_player_unsupported_link_type,
|
||||
Toast.LENGTH_LONG).show();
|
||||
handleChoice(showInfoKey);
|
||||
return;
|
||||
}
|
||||
if (currentLinkType != LinkType.STREAM
|
||||
&& ((isExtAudioEnabled && isAudioPlayerSelected)
|
||||
|| (isExtVideoEnabled && isVideoPlayerSelected))
|
||||
) {
|
||||
Toast.makeText(this, R.string.external_player_unsupported_link_type,
|
||||
Toast.LENGTH_LONG).show();
|
||||
handleChoice(getString(R.string.show_info_key));
|
||||
return;
|
||||
}
|
||||
|
||||
final List<StreamingService.ServiceInfo.MediaCapability> capabilities
|
||||
= currentService.getServiceInfo().getMediaCapabilities();
|
||||
final List<StreamingService.ServiceInfo.MediaCapability> capabilities =
|
||||
currentService.getServiceInfo().getMediaCapabilities();
|
||||
|
||||
boolean serviceSupportsChoice = false;
|
||||
if (isVideoPlayerSelected) {
|
||||
serviceSupportsChoice = capabilities.contains(VIDEO);
|
||||
} else if (selectedChoiceKey.equals(backgroundPlayerKey)) {
|
||||
serviceSupportsChoice = capabilities.contains(AUDIO);
|
||||
}
|
||||
|
||||
if (serviceSupportsChoice) {
|
||||
handleChoice(selectedChoiceKey);
|
||||
// Check if the service supports the choice
|
||||
if ((isVideoPlayerSelected && capabilities.contains(VIDEO))
|
||||
|| (isAudioPlayerSelected && capabilities.contains(AUDIO))) {
|
||||
handleChoice(selectedChoice);
|
||||
} 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) {
|
||||
final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
|
||||
final Context themeWrapperContext = getThemeWrapperContext();
|
||||
|
||||
final LayoutInflater inflater = LayoutInflater.from(themeWrapperContext);
|
||||
final RadioGroup radioGroup = SingleChoiceDialogViewBinding.inflate(getLayoutInflater())
|
||||
.list;
|
||||
final Context themeWrapperContext = getThemeWrapperContext();
|
||||
final LayoutInflater layoutInflater = LayoutInflater.from(themeWrapperContext);
|
||||
|
||||
final SingleChoiceDialogViewBinding binding =
|
||||
SingleChoiceDialogViewBinding.inflate(layoutInflater);
|
||||
final RadioGroup radioGroup = binding.list;
|
||||
|
||||
final DialogInterface.OnClickListener dialogButtonsClickListener = (dialog, which) -> {
|
||||
final int indexOfChild = radioGroup.indexOfChild(
|
||||
|
@ -349,21 +392,19 @@ public class RouterActivity extends AppCompatActivity {
|
|||
|
||||
alertDialogChoice = new AlertDialog.Builder(themeWrapperContext)
|
||||
.setTitle(R.string.preferred_open_action_share_menu_title)
|
||||
.setView(radioGroup)
|
||||
.setView(binding.getRoot())
|
||||
.setCancelable(true)
|
||||
.setNegativeButton(R.string.just_once, dialogButtonsClickListener)
|
||||
.setPositiveButton(R.string.always, dialogButtonsClickListener)
|
||||
.setOnDismissListener((dialog) -> {
|
||||
.setOnDismissListener(dialog -> {
|
||||
if (!selectionIsDownload && !selectionIsAddToPlaylist) {
|
||||
finish();
|
||||
}
|
||||
})
|
||||
.create();
|
||||
|
||||
//noinspection CodeBlock2Expr
|
||||
alertDialogChoice.setOnShowListener(dialog -> {
|
||||
setDialogButtonsState(alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1);
|
||||
});
|
||||
alertDialogChoice.setOnShowListener(dialog -> setDialogButtonsState(
|
||||
alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1));
|
||||
|
||||
radioGroup.setOnCheckedChangeListener((group, checkedId) ->
|
||||
setDialogButtonsState(alertDialogChoice, true));
|
||||
|
@ -383,9 +424,10 @@ public class RouterActivity extends AppCompatActivity {
|
|||
|
||||
int id = 12345;
|
||||
for (final AdapterChoiceItem item : choices) {
|
||||
final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater).getRoot();
|
||||
final RadioButton radioButton = ListRadioIconItemBinding.inflate(layoutInflater)
|
||||
.getRoot();
|
||||
radioButton.setText(item.description);
|
||||
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(radioButton,
|
||||
radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
AppCompatResources.getDrawable(themeWrapperContext, item.icon),
|
||||
null, null, null);
|
||||
radioButton.setChecked(false);
|
||||
|
@ -425,87 +467,64 @@ public class RouterActivity extends AppCompatActivity {
|
|||
|
||||
private List<AdapterChoiceItem> getChoicesForService(final StreamingService service,
|
||||
final LinkType linkType) {
|
||||
final Context context = getThemeWrapperContext();
|
||||
|
||||
final List<AdapterChoiceItem> returnList = new ArrayList<>();
|
||||
final List<StreamingService.ServiceInfo.MediaCapability> capabilities
|
||||
= service.getServiceInfo().getMediaCapabilities();
|
||||
|
||||
final SharedPreferences preferences = PreferenceManager
|
||||
.getDefaultSharedPreferences(this);
|
||||
final boolean isExtVideoEnabled = preferences.getBoolean(
|
||||
getString(R.string.use_external_video_player_key), false);
|
||||
final boolean isExtAudioEnabled = preferences.getBoolean(
|
||||
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 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 addToPlaylist = new AdapterChoiceItem(
|
||||
getString(R.string.add_to_playlist_key), getString(R.string.add_to_playlist),
|
||||
R.drawable.ic_add);
|
||||
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> returnedItems = new ArrayList<>();
|
||||
returnedItems.add(showInfo); // Always present
|
||||
|
||||
final List<StreamingService.ServiceInfo.MediaCapability> capabilities =
|
||||
service.getServiceInfo().getMediaCapabilities();
|
||||
|
||||
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);
|
||||
returnedItems.add(videoPlayer);
|
||||
returnedItems.add(popupPlayer);
|
||||
}
|
||||
if (capabilities.contains(AUDIO)) {
|
||||
returnList.add(backgroundPlayer);
|
||||
returnedItems.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),
|
||||
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
|
||||
returnList.add(addToPlaylist);
|
||||
|
||||
returnedItems.add(new AdapterChoiceItem(getString(R.string.add_to_playlist_key),
|
||||
getString(R.string.add_to_playlist),
|
||||
R.drawable.ic_add));
|
||||
} else {
|
||||
returnList.add(showInfo);
|
||||
// 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
|
||||
.getDefaultSharedPreferences(this);
|
||||
final boolean isExtVideoEnabled = preferences.getBoolean(
|
||||
getString(R.string.use_external_video_player_key), false);
|
||||
final boolean isExtAudioEnabled = preferences.getBoolean(
|
||||
getString(R.string.use_external_audio_player_key), false);
|
||||
|
||||
if (capabilities.contains(VIDEO) && !isExtVideoEnabled) {
|
||||
returnList.add(videoPlayer);
|
||||
returnList.add(popupPlayer);
|
||||
returnedItems.add(videoPlayer);
|
||||
returnedItems.add(popupPlayer);
|
||||
}
|
||||
if (capabilities.contains(AUDIO) && !isExtAudioEnabled) {
|
||||
returnList.add(backgroundPlayer);
|
||||
returnedItems.add(backgroundPlayer);
|
||||
}
|
||||
}
|
||||
|
||||
return returnList;
|
||||
return returnedItems;
|
||||
}
|
||||
|
||||
private Context getThemeWrapperContext() {
|
||||
|
@ -567,7 +586,8 @@ public class RouterActivity extends AppCompatActivity {
|
|||
|
||||
// stop and bypass FetcherService if InfoScreen was selected since
|
||||
// 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
|
||||
.fromCallable(() -> NavigationHelper.getIntentByLink(this, currentUrl))
|
||||
.subscribeOn(Schedulers.io())
|
||||
|
@ -590,6 +610,30 @@ public class RouterActivity extends AppCompatActivity {
|
|||
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() {
|
||||
// Getting the stream info usually takes a moment
|
||||
// Notifying the user here to ensure that no confusion arises
|
||||
|
@ -631,22 +675,13 @@ public class RouterActivity extends AppCompatActivity {
|
|||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(result -> {
|
||||
final List<VideoStream> sortedVideoStreams = ListHelper
|
||||
.getSortedStreamVideosList(this, result.getVideoStreams(),
|
||||
result.getVideoOnlyStreams(), false);
|
||||
final int selectedVideoStreamIndex = ListHelper
|
||||
.getDefaultResolutionIndex(this, sortedVideoStreams);
|
||||
final DownloadDialog downloadDialog = new DownloadDialog(this, result);
|
||||
downloadDialog.setOnDismissListener(dialog -> finish());
|
||||
|
||||
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");
|
||||
fm.executePendingTransactions();
|
||||
}, throwable ->
|
||||
showUnsupportedUrlDialog(currentUrl)));
|
||||
}, throwable -> showUnsupportedUrlDialog(currentUrl)));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -672,8 +707,8 @@ public class RouterActivity extends AppCompatActivity {
|
|||
final int icon;
|
||||
|
||||
AdapterChoiceItem(final String key, final String description, final int icon) {
|
||||
this.description = description;
|
||||
this.key = key;
|
||||
this.description = description;
|
||||
this.icon = icon;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,6 @@ import androidx.appcompat.app.AppCompatActivity
|
|||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
import org.schabi.newpipe.R
|
||||
|
@ -21,30 +20,28 @@ import org.schabi.newpipe.util.ThemeHelper
|
|||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
|
||||
class AboutActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Localization.assureCorrectAppLanguage(this)
|
||||
super.onCreate(savedInstanceState)
|
||||
ThemeHelper.setTheme(this)
|
||||
title = getString(R.string.title_activity_about)
|
||||
|
||||
val aboutBinding = ActivityAboutBinding.inflate(layoutInflater)
|
||||
setContentView(aboutBinding.root)
|
||||
setSupportActionBar(aboutBinding.aboutToolbar)
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
// Create the adapter that will return a fragment for each of the three
|
||||
// primary sections of the activity.
|
||||
val mAboutStateAdapter = AboutStateAdapter(this)
|
||||
|
||||
// Set up the ViewPager with the sections adapter.
|
||||
aboutBinding.aboutViewPager2.adapter = mAboutStateAdapter
|
||||
TabLayoutMediator(
|
||||
aboutBinding.aboutTabLayout,
|
||||
aboutBinding.aboutViewPager2
|
||||
) { tab: TabLayout.Tab, position: Int ->
|
||||
when (position) {
|
||||
POS_ABOUT -> tab.setText(R.string.tab_about)
|
||||
POS_LICENSE -> tab.setText(R.string.tab_licenses)
|
||||
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
|
||||
}
|
||||
) { tab, position ->
|
||||
tab.setText(mAboutStateAdapter.getPageTitle(position))
|
||||
}.attach()
|
||||
}
|
||||
|
||||
|
@ -75,13 +72,14 @@ class AboutActivity : AppCompatActivity() {
|
|||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val aboutBinding = FragmentAboutBinding.inflate(inflater, container, false)
|
||||
aboutBinding.aboutAppVersion.text = BuildConfig.VERSION_NAME
|
||||
aboutBinding.aboutGithubLink.openLink(R.string.github_url)
|
||||
aboutBinding.aboutDonationLink.openLink(R.string.donation_url)
|
||||
aboutBinding.aboutWebsiteLink.openLink(R.string.website_url)
|
||||
aboutBinding.aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url)
|
||||
return aboutBinding.root
|
||||
FragmentAboutBinding.inflate(inflater, container, false).apply {
|
||||
aboutAppVersion.text = BuildConfig.VERSION_NAME
|
||||
aboutGithubLink.openLink(R.string.github_url)
|
||||
aboutDonationLink.openLink(R.string.donation_url)
|
||||
aboutWebsiteLink.openLink(R.string.website_url)
|
||||
aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url)
|
||||
return root
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -90,17 +88,29 @@ class AboutActivity : AppCompatActivity() {
|
|||
* one of the sections/tabs/pages.
|
||||
*/
|
||||
private class AboutStateAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
|
||||
private val posAbout = 0
|
||||
private val posLicense = 1
|
||||
private val totalCount = 2
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
return when (position) {
|
||||
POS_ABOUT -> AboutFragment()
|
||||
POS_LICENSE -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS)
|
||||
posAbout -> AboutFragment()
|
||||
posLicense -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS)
|
||||
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
// Show 2 total pages.
|
||||
return TOTAL_COUNT
|
||||
return totalCount
|
||||
}
|
||||
|
||||
fun getPageTitle(position: Int): Int {
|
||||
return when (position) {
|
||||
posAbout -> R.string.tab_about
|
||||
posLicense -> R.string.tab_licenses
|
||||
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -117,10 +127,6 @@ class AboutActivity : AppCompatActivity() {
|
|||
"AndroidX", "2005 - 2011", "The Android Open Source Project",
|
||||
"https://developer.android.com/jetpack", StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"CircleImageView", "2014 - 2020", "Henning Dodenhof",
|
||||
"https://github.com/hdodenhof/CircleImageView", StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"ExoPlayer", "2014 - 2020", "Google, Inc.",
|
||||
"https://github.com/google/ExoPlayer", StandardLicenses.APACHE2
|
||||
|
@ -191,8 +197,5 @@ class AboutActivity : AppCompatActivity() {
|
|||
"https://github.com/ByteHamster/SearchPreference", StandardLicenses.MIT
|
||||
),
|
||||
)
|
||||
private const val POS_ABOUT = 0
|
||||
private const val POS_LICENSE = 1
|
||||
private const val TOTAL_COUNT = 2
|
||||
}
|
||||
}
|
||||
|
|
|
@ -87,60 +87,50 @@ object LicenseFragmentHelper {
|
|||
return context.getString(color).substring(3)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun showLicense(context: Context?, license: License): Disposable {
|
||||
return showLicense(context, license) { alertDialog ->
|
||||
alertDialog.setPositiveButton(R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showLicense(context: Context?, component: SoftwareComponent): Disposable {
|
||||
return showLicense(context, component.license) { alertDialog ->
|
||||
alertDialog.setPositiveButton(R.string.dismiss) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
alertDialog.setNeutralButton(R.string.open_website_license) { _, _ ->
|
||||
ShareUtils.openUrlInBrowser(context!!, component.link)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLicense(
|
||||
context: Context?,
|
||||
license: License,
|
||||
block: (AlertDialog.Builder) -> Unit
|
||||
): Disposable {
|
||||
return if (context == null) {
|
||||
Disposable.empty()
|
||||
} else {
|
||||
Observable.fromCallable { getFormattedLicense(context, license) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { formattedLicense: String ->
|
||||
.subscribe { formattedLicense ->
|
||||
val webViewData = Base64.encodeToString(
|
||||
formattedLicense
|
||||
.toByteArray(StandardCharsets.UTF_8),
|
||||
Base64.NO_PADDING
|
||||
formattedLicense.toByteArray(StandardCharsets.UTF_8), Base64.NO_PADDING
|
||||
)
|
||||
val webView = WebView(context)
|
||||
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
|
||||
val alert = AlertDialog.Builder(context)
|
||||
alert.setTitle(license.name)
|
||||
alert.setView(webView)
|
||||
Localization.assureCorrectAppLanguage(context)
|
||||
alert.setNegativeButton(
|
||||
context.getString(R.string.ok)
|
||||
) { dialog, _ -> dialog.dismiss() }
|
||||
alert.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@JvmStatic
|
||||
fun showLicense(context: Context?, component: SoftwareComponent): Disposable {
|
||||
return if (context == null) {
|
||||
Disposable.empty()
|
||||
} else {
|
||||
Observable.fromCallable { getFormattedLicense(context, component.license) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { formattedLicense: String ->
|
||||
val webViewData = Base64.encodeToString(
|
||||
formattedLicense
|
||||
.toByteArray(StandardCharsets.UTF_8),
|
||||
Base64.NO_PADDING
|
||||
)
|
||||
val webView = WebView(context)
|
||||
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
|
||||
val alert = AlertDialog.Builder(context)
|
||||
alert.setTitle(component.license.name)
|
||||
alert.setView(webView)
|
||||
Localization.assureCorrectAppLanguage(context)
|
||||
alert.setPositiveButton(
|
||||
R.string.dismiss
|
||||
) { dialog, _ -> dialog.dismiss() }
|
||||
alert.setNeutralButton(R.string.open_website_license) { _, _ ->
|
||||
ShareUtils.openUrlInBrowser(context, component.link)
|
||||
|
||||
AlertDialog.Builder(context).apply {
|
||||
setTitle(license.name)
|
||||
setView(webView)
|
||||
Localization.assureCorrectAppLanguage(context)
|
||||
block(this)
|
||||
show()
|
||||
}
|
||||
alert.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package org.schabi.newpipe.database;
|
||||
|
||||
import static org.schabi.newpipe.database.Migrations.DB_VER_5;
|
||||
|
||||
import androidx.room.Database;
|
||||
import androidx.room.RoomDatabase;
|
||||
import androidx.room.TypeConverters;
|
||||
|
@ -27,8 +29,6 @@ import org.schabi.newpipe.database.stream.model.StreamStateEntity;
|
|||
import org.schabi.newpipe.database.subscription.SubscriptionDAO;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
|
||||
import static org.schabi.newpipe.database.Migrations.DB_VER_4;
|
||||
|
||||
@TypeConverters({Converters.class})
|
||||
@Database(
|
||||
entities = {
|
||||
|
@ -38,7 +38,7 @@ import static org.schabi.newpipe.database.Migrations.DB_VER_4;
|
|||
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
|
||||
FeedLastUpdatedEntity.class
|
||||
},
|
||||
version = DB_VER_4
|
||||
version = DB_VER_5
|
||||
)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
public static final String DATABASE_NAME = "newpipe.db";
|
||||
|
|
|
@ -22,6 +22,7 @@ public final class Migrations {
|
|||
public static final int DB_VER_2 = 2;
|
||||
public static final int DB_VER_3 = 3;
|
||||
public static final int DB_VER_4 = 4;
|
||||
public static final int DB_VER_5 = 5;
|
||||
|
||||
private static final String TAG = Migrations.class.getName();
|
||||
public static final boolean DEBUG = MainActivity.DEBUG;
|
||||
|
@ -179,5 +180,14 @@ public final class Migrations {
|
|||
}
|
||||
};
|
||||
|
||||
private Migrations() { }
|
||||
public static final Migration MIGRATION_4_5 = new Migration(DB_VER_4, DB_VER_5) {
|
||||
@Override
|
||||
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` "
|
||||
+ "INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
};
|
||||
|
||||
private Migrations() {
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import org.schabi.newpipe.database.feed.model.FeedEntity
|
|||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
||||
import org.schabi.newpipe.database.stream.StreamWithState
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
|
@ -252,4 +253,21 @@ abstract class FeedDAO {
|
|||
"""
|
||||
)
|
||||
abstract fun getAllOutdatedForGroup(groupId: Long, outdatedThreshold: OffsetDateTime): Flowable<List<SubscriptionEntity>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT s.* FROM subscriptions s
|
||||
|
||||
LEFT JOIN feed_last_updated lu
|
||||
ON s.uid = lu.subscription_id
|
||||
|
||||
WHERE
|
||||
(lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold)
|
||||
AND s.notification_mode = :notificationMode
|
||||
"""
|
||||
)
|
||||
abstract fun getOutdatedWithNotificationMode(
|
||||
outdatedThreshold: OffsetDateTime,
|
||||
@NotificationMode notificationMode: Int
|
||||
): Flowable<List<SubscriptionEntity>>
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package org.schabi.newpipe.database.history.dao;
|
|||
import androidx.annotation.Nullable;
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Query;
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns;
|
||||
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity;
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntry;
|
||||
|
@ -67,6 +68,7 @@ public abstract class StreamHistoryDAO implements HistoryDAO<StreamHistoryEntity
|
|||
@Query("DELETE FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
|
||||
public abstract int deleteStreamHistory(long streamId);
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query("SELECT * FROM " + STREAM_TABLE
|
||||
|
||||
// Select the latest entry and watch count for each stream id on history table
|
||||
|
|
|
@ -4,7 +4,6 @@ import androidx.annotation.NonNull;
|
|||
import androidx.room.ColumnInfo;
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.ForeignKey;
|
||||
import androidx.room.Ignore;
|
||||
import androidx.room.Index;
|
||||
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
|
@ -42,18 +41,19 @@ public class StreamHistoryEntity {
|
|||
@ColumnInfo(name = STREAM_REPEAT_COUNT)
|
||||
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) {
|
||||
this.streamUid = streamUid;
|
||||
this.accessDate = accessDate;
|
||||
this.repeatCount = repeatCount;
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public StreamHistoryEntity(final long streamUid, @NonNull final OffsetDateTime accessDate) {
|
||||
this(streamUid, accessDate, 1);
|
||||
}
|
||||
|
||||
public long getStreamUid() {
|
||||
return streamUid;
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package org.schabi.newpipe.database.playlist.dao;
|
|||
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Query;
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns;
|
||||
import androidx.room.Transaction;
|
||||
|
||||
import org.schabi.newpipe.database.BasicDAO;
|
||||
|
@ -52,6 +53,7 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
|
|||
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
|
||||
Flowable<Integer> getMaximumIndexOf(long playlistId);
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Transaction
|
||||
@Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN "
|
||||
// get ids of streams of the given playlist
|
||||
|
|
|
@ -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.Companion.STREAM_ID
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM
|
||||
import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM
|
||||
import org.schabi.newpipe.util.StreamTypeUtil
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@Dao
|
||||
|
@ -39,6 +38,9 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
|
|||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
internal abstract fun silentInsertAllInternal(streams: List<StreamEntity>): List<Long>
|
||||
|
||||
@Query("SELECT COUNT(*) != 0 FROM streams WHERE url = :url AND service_id = :serviceId")
|
||||
internal abstract fun exists(serviceId: Int, url: String): Boolean
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration
|
||||
|
@ -88,8 +90,7 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
|
|||
?: throw IllegalStateException("Stream cannot be null just after insertion.")
|
||||
newerStream.uid = existentMinimalStream.uid
|
||||
|
||||
val isNewerStreamLive = newerStream.streamType == AUDIO_LIVE_STREAM || newerStream.streamType == LIVE_STREAM
|
||||
if (!isNewerStreamLive) {
|
||||
if (!StreamTypeUtil.isLiveStream(newerStream.streamType)) {
|
||||
|
||||
// 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.
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
package org.schabi.newpipe.database.subscription;
|
||||
|
||||
import androidx.annotation.IntDef;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
|
||||
@IntDef({NotificationMode.DISABLED, NotificationMode.ENABLED})
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
public @interface NotificationMode {
|
||||
|
||||
int DISABLED = 0;
|
||||
int ENABLED = 1;
|
||||
//other values reserved for the future
|
||||
}
|
|
@ -4,6 +4,7 @@ import androidx.room.Dao
|
|||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||
import androidx.room.Transaction
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
|
@ -31,6 +32,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
|||
)
|
||||
abstract fun getSubscriptionsFiltered(filter: String): Flowable<List<SubscriptionEntity>>
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM subscriptions s
|
||||
|
@ -47,6 +49,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
|||
currentGroupId: Long
|
||||
): Flowable<List<SubscriptionEntity>>
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM subscriptions s
|
||||
|
|
|
@ -26,6 +26,7 @@ public class SubscriptionEntity {
|
|||
public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url";
|
||||
public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
|
||||
public static final String SUBSCRIPTION_DESCRIPTION = "description";
|
||||
public static final String SUBSCRIPTION_NOTIFICATION_MODE = "notification_mode";
|
||||
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
private long uid = 0;
|
||||
|
@ -48,6 +49,9 @@ public class SubscriptionEntity {
|
|||
@ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
|
||||
private String description;
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE)
|
||||
private int notificationMode;
|
||||
|
||||
@Ignore
|
||||
public static SubscriptionEntity from(@NonNull final ChannelInfo info) {
|
||||
final SubscriptionEntity result = new SubscriptionEntity();
|
||||
|
@ -114,6 +118,15 @@ public class SubscriptionEntity {
|
|||
this.description = description;
|
||||
}
|
||||
|
||||
@NotificationMode
|
||||
public int getNotificationMode() {
|
||||
return notificationMode;
|
||||
}
|
||||
|
||||
public void setNotificationMode(@NotificationMode final int notificationMode) {
|
||||
this.notificationMode = notificationMode;
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public void setData(final String n, final String au, final String d, final Long sc) {
|
||||
this.setName(n);
|
||||
|
|
|
@ -61,6 +61,7 @@ import org.schabi.newpipe.util.FilenameUtils;
|
|||
import org.schabi.newpipe.util.ListHelper;
|
||||
import org.schabi.newpipe.util.PermissionHelper;
|
||||
import org.schabi.newpipe.util.SecondaryStreamHelper;
|
||||
import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
|
||||
import org.schabi.newpipe.util.StreamItemAdapter;
|
||||
import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
@ -68,9 +69,9 @@ import org.schabi.newpipe.util.VideoSegment;
|
|||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
import icepick.Icepick;
|
||||
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.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;
|
||||
|
||||
public class DownloadDialog extends DialogFragment
|
||||
|
@ -92,17 +95,17 @@ public class DownloadDialog extends DialogFragment
|
|||
@State
|
||||
StreamInfo currentInfo;
|
||||
@State
|
||||
StreamSizeWrapper<AudioStream> wrappedAudioStreams = StreamSizeWrapper.empty();
|
||||
StreamSizeWrapper<AudioStream> wrappedAudioStreams;
|
||||
@State
|
||||
StreamSizeWrapper<VideoStream> wrappedVideoStreams = StreamSizeWrapper.empty();
|
||||
StreamSizeWrapper<VideoStream> wrappedVideoStreams;
|
||||
@State
|
||||
StreamSizeWrapper<SubtitlesStream> wrappedSubtitleStreams = StreamSizeWrapper.empty();
|
||||
StreamSizeWrapper<SubtitlesStream> wrappedSubtitleStreams;
|
||||
@State
|
||||
int selectedVideoIndex = 0;
|
||||
int selectedVideoIndex; // set in the constructor
|
||||
@State
|
||||
int selectedAudioIndex = 0;
|
||||
int selectedAudioIndex = 0; // default to the first item
|
||||
@State
|
||||
int selectedSubtitleIndex = 0;
|
||||
int selectedSubtitleIndex = 0; // default to the first item
|
||||
|
||||
@Nullable
|
||||
private OnDismissListener onDismissListener = null;
|
||||
|
@ -145,81 +148,47 @@ public class DownloadDialog extends DialogFragment
|
|||
// Instance creation
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
public static DownloadDialog newInstance(final StreamInfo info) {
|
||||
final DownloadDialog dialog = new DownloadDialog();
|
||||
dialog.setInfo(info);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
public static DownloadDialog newInstance(final Context context, final StreamInfo info) {
|
||||
final ArrayList<VideoStream> streamsList = new ArrayList<>(ListHelper
|
||||
.getSortedStreamVideosList(context, info.getVideoStreams(),
|
||||
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) {
|
||||
/**
|
||||
* Create a new download dialog with the video, audio and subtitle streams from the provided
|
||||
* stream info. Video streams and video-only streams will be put into a single list menu,
|
||||
* 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)
|
||||
* @param info the info from which to obtain downloadable streams and other info (e.g. title)
|
||||
*/
|
||||
public DownloadDialog(final Context context, @NonNull final StreamInfo info) {
|
||||
this.currentInfo = info;
|
||||
}
|
||||
|
||||
public void setAudioStreams(final List<AudioStream> audioStreams) {
|
||||
setAudioStreams(new StreamSizeWrapper<>(audioStreams, getContext()));
|
||||
}
|
||||
// TODO: Adapt this code when the downloader support other types of stream deliveries
|
||||
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.wrappedAudioStreams = was;
|
||||
}
|
||||
this.wrappedVideoStreams = new StreamSizeWrapper<>(videoStreams, context);
|
||||
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) {
|
||||
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;
|
||||
this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams);
|
||||
}
|
||||
|
||||
public void setVideoSegments(final VideoSegment[] seg) {
|
||||
this.segments = seg;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param onDismissListener the listener to call in {@link #onDismiss(DialogInterface)}
|
||||
*/
|
||||
public void setOnDismissListener(@Nullable final OnDismissListener onDismissListener) {
|
||||
this.onDismissListener = onDismissListener;
|
||||
}
|
||||
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Android lifecycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
@ -255,11 +224,16 @@ public class DownloadDialog extends DialogFragment
|
|||
.getAudioStreamFor(wrappedAudioStreams.getStreamsList(), videoStreams.get(i));
|
||||
|
||||
if (audioStream != null) {
|
||||
secondaryStreams
|
||||
.append(i, new SecondaryStreamHelper<>(wrappedAudioStreams, audioStream));
|
||||
secondaryStreams.append(i, new SecondaryStreamHelper<>(wrappedAudioStreams,
|
||||
audioStream));
|
||||
} else if (DEBUG) {
|
||||
Log.w(TAG, "No audio stream candidates for video format "
|
||||
+ videoStreams.get(i).getFormat().name());
|
||||
final MediaFormat mediaFormat = videoStreams.get(i).getFormat();
|
||||
if (mediaFormat != null) {
|
||||
Log.w(TAG, "No audio stream candidates for video format "
|
||||
+ mediaFormat.name());
|
||||
} else {
|
||||
Log.w(TAG, "No audio stream candidates for unknown video format");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -294,7 +268,8 @@ public class DownloadDialog extends DialogFragment
|
|||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container,
|
||||
public View onCreateView(@NonNull final LayoutInflater inflater,
|
||||
final ViewGroup container,
|
||||
final Bundle savedInstanceState) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreateView() called with: "
|
||||
|
@ -305,14 +280,15 @@ public class DownloadDialog extends DialogFragment
|
|||
}
|
||||
|
||||
@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);
|
||||
dialogBinding = DownloadDialogBinding.bind(view);
|
||||
|
||||
dialogBinding.fileName.setText(FilenameUtils.createFilename(getContext(),
|
||||
currentInfo.getName()));
|
||||
selectedAudioIndex = ListHelper
|
||||
.getDefaultAudioFormat(getContext(), currentInfo.getAudioStreams());
|
||||
.getDefaultAudioFormat(getContext(), wrappedAudioStreams.getStreamsList());
|
||||
|
||||
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);
|
||||
dialogBinding.threadsCount.setText(String.valueOf(threads));
|
||||
dialogBinding.threads.setProgress(threads - 1);
|
||||
dialogBinding.threads.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
|
||||
dialogBinding.threads.setOnSeekBarChangeListener(new SimpleOnSeekBarChangeListener() {
|
||||
@Override
|
||||
public void onProgressChanged(final SeekBar seekbar, final int progress,
|
||||
public void onProgressChanged(@NonNull final SeekBar seekbar,
|
||||
final int progress,
|
||||
final boolean fromUser) {
|
||||
final int newProgress = progress + 1;
|
||||
prefs.edit().putInt(getString(R.string.default_download_threads), newProgress)
|
||||
.apply();
|
||||
dialogBinding.threadsCount.setText(String.valueOf(newProgress));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStartTrackingTouch(final SeekBar p1) { }
|
||||
|
||||
@Override
|
||||
public void onStopTrackingTouch(final SeekBar p1) { }
|
||||
});
|
||||
|
||||
fetchStreamsSize();
|
||||
|
@ -481,7 +452,7 @@ public class DownloadDialog extends DialogFragment
|
|||
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) {
|
||||
return;
|
||||
}
|
||||
|
@ -498,8 +469,8 @@ public class DownloadDialog extends DialogFragment
|
|||
return;
|
||||
}
|
||||
|
||||
final DocumentFile docFile
|
||||
= DocumentFile.fromSingleUri(context, result.getData().getData());
|
||||
final DocumentFile docFile = DocumentFile.fromSingleUri(context,
|
||||
result.getData().getData());
|
||||
if (docFile == null) {
|
||||
showFailedDialog(R.string.general_error);
|
||||
return;
|
||||
|
@ -510,7 +481,7 @@ public class DownloadDialog extends DialogFragment
|
|||
docFile.getType());
|
||||
}
|
||||
|
||||
private void requestDownloadPickFolderResult(final ActivityResult result,
|
||||
private void requestDownloadPickFolderResult(@NonNull final ActivityResult result,
|
||||
final String key,
|
||||
final String tag) {
|
||||
if (result.getResultCode() != Activity.RESULT_OK) {
|
||||
|
@ -530,12 +501,11 @@ public class DownloadDialog extends DialogFragment
|
|||
StoredDirectoryHelper.PERMISSION_FLAGS);
|
||||
}
|
||||
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
.putString(key, uri.toString()).apply();
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit().putString(key,
|
||||
uri.toString()).apply();
|
||||
|
||||
try {
|
||||
final StoredDirectoryHelper mainStorage
|
||||
= new StoredDirectoryHelper(context, uri, tag);
|
||||
final StoredDirectoryHelper mainStorage = new StoredDirectoryHelper(context, uri, tag);
|
||||
checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp),
|
||||
filenameTmp, mimeTmp);
|
||||
} catch (final IOException e) {
|
||||
|
@ -573,8 +543,10 @@ public class DownloadDialog extends DialogFragment
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onItemSelected(final AdapterView<?> parent, final View view,
|
||||
final int position, final long id) {
|
||||
public void onItemSelected(final AdapterView<?> parent,
|
||||
final View view,
|
||||
final int position,
|
||||
final long id) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onItemSelected() called with: "
|
||||
+ "parent = [" + parent + "], view = [" + view + "], "
|
||||
|
@ -609,14 +581,16 @@ public class DownloadDialog extends DialogFragment
|
|||
final boolean isAudioStreamsAvailable = audioStreamsAdapter.getCount() > 0;
|
||||
final boolean isSubtitleStreamsAvailable = subtitleStreamsAdapter.getCount() > 0;
|
||||
|
||||
dialogBinding.audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE : View.GONE);
|
||||
dialogBinding.videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE : View.GONE);
|
||||
dialogBinding.audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE
|
||||
: View.GONE);
|
||||
dialogBinding.videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE
|
||||
: View.GONE);
|
||||
dialogBinding.subtitleButton.setVisibility(isSubtitleStreamsAvailable
|
||||
? View.VISIBLE : View.GONE);
|
||||
|
||||
prefs = PreferenceManager.getDefaultSharedPreferences(requireContext());
|
||||
final String defaultMedia = prefs.getString(getString(R.string.last_used_download_type),
|
||||
getString(R.string.last_download_type_video_key));
|
||||
getString(R.string.last_download_type_video_key));
|
||||
|
||||
if (isVideoStreamsAvailable
|
||||
&& (defaultMedia.equals(getString(R.string.last_download_type_video_key)))) {
|
||||
|
@ -652,7 +626,7 @@ public class DownloadDialog extends DialogFragment
|
|||
dialogBinding.subtitleButton.setEnabled(enabled);
|
||||
}
|
||||
|
||||
private int getSubtitleIndexBy(final List<SubtitlesStream> streams) {
|
||||
private int getSubtitleIndexBy(@NonNull final List<SubtitlesStream> streams) {
|
||||
final Localization preferredLocalization = NewPipe.getPreferredLocalization();
|
||||
|
||||
int candidate = 0;
|
||||
|
@ -678,8 +652,10 @@ public class DownloadDialog extends DialogFragment
|
|||
return candidate;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
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);
|
||||
}
|
||||
|
@ -695,12 +671,8 @@ public class DownloadDialog extends DialogFragment
|
|||
}
|
||||
|
||||
private void launchDirectoryPicker(final ActivityResultLauncher<Intent> launcher) {
|
||||
NoFileManagerSafeGuard.launchSafe(
|
||||
launcher,
|
||||
StoredDirectoryHelper.getPicker(context),
|
||||
TAG,
|
||||
context
|
||||
);
|
||||
NoFileManagerSafeGuard.launchSafe(launcher, StoredDirectoryHelper.getPicker(context), TAG,
|
||||
context);
|
||||
}
|
||||
|
||||
private void prepareSelectedDownload() {
|
||||
|
@ -721,7 +693,7 @@ public class DownloadDialog extends DialogFragment
|
|||
if (format == MediaFormat.WEBMA_OPUS) {
|
||||
mimeTmp = "audio/ogg";
|
||||
filenameTmp += "opus";
|
||||
} else {
|
||||
} else if (format != null) {
|
||||
mimeTmp = format.mimeType;
|
||||
filenameTmp += format.suffix;
|
||||
}
|
||||
|
@ -730,22 +702,30 @@ public class DownloadDialog extends DialogFragment
|
|||
selectedMediaType = getString(R.string.last_download_type_video_key);
|
||||
mainStorage = mainStorageVideo;
|
||||
format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat();
|
||||
mimeTmp = format.mimeType;
|
||||
filenameTmp += format.suffix;
|
||||
if (format != null) {
|
||||
mimeTmp = format.mimeType;
|
||||
filenameTmp += format.suffix;
|
||||
}
|
||||
break;
|
||||
case R.id.subtitle_button:
|
||||
selectedMediaType = getString(R.string.last_download_type_subtitle_key);
|
||||
mainStorage = mainStorageVideo; // subtitle & video files go together
|
||||
format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat();
|
||||
mimeTmp = format.mimeType;
|
||||
filenameTmp += (format == MediaFormat.TTML ? MediaFormat.SRT : format).suffix;
|
||||
if (format != null) {
|
||||
mimeTmp = format.mimeType;
|
||||
}
|
||||
|
||||
if (format == MediaFormat.TTML) {
|
||||
filenameTmp += MediaFormat.SRT.suffix;
|
||||
} else if (format != null) {
|
||||
filenameTmp += format.suffix;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new RuntimeException("No stream selected");
|
||||
}
|
||||
|
||||
if (!askForSavePath
|
||||
&& (mainStorage == null
|
||||
if (!askForSavePath && (mainStorage == null
|
||||
|| mainStorage.isDirect() == NewPipeSettings.useStorageAccessFramework(context)
|
||||
|| mainStorage.isInvalidSafStorage())) {
|
||||
// Pick new download folder if one of:
|
||||
|
@ -779,18 +759,16 @@ public class DownloadDialog extends DialogFragment
|
|||
initialPath = Uri.parse(initialSavePath.getAbsolutePath());
|
||||
}
|
||||
|
||||
NoFileManagerSafeGuard.launchSafe(
|
||||
requestDownloadSaveAsLauncher,
|
||||
StoredFileHelper.getNewPicker(context, filenameTmp, mimeTmp, initialPath),
|
||||
TAG,
|
||||
context
|
||||
);
|
||||
NoFileManagerSafeGuard.launchSafe(requestDownloadSaveAsLauncher,
|
||||
StoredFileHelper.getNewPicker(context, filenameTmp, mimeTmp, initialPath), TAG,
|
||||
context);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 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
|
||||
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,
|
||||
final Uri targetFile, final String filename,
|
||||
final Uri targetFile,
|
||||
final String filename,
|
||||
final String mime) {
|
||||
StoredFileHelper storage;
|
||||
|
||||
|
@ -959,7 +938,7 @@ public class DownloadDialog extends DialogFragment
|
|||
storage.truncate();
|
||||
}
|
||||
} 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);
|
||||
return;
|
||||
}
|
||||
|
@ -1004,8 +983,8 @@ public class DownloadDialog extends DialogFragment
|
|||
}
|
||||
|
||||
psArgs = null;
|
||||
final long videoSize = wrappedVideoStreams
|
||||
.getSizeInBytes((VideoStream) selectedStream);
|
||||
final long videoSize = wrappedVideoStreams.getSizeInBytes(
|
||||
(VideoStream) selectedStream);
|
||||
|
||||
// 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
|
||||
|
@ -1021,7 +1000,7 @@ public class DownloadDialog extends DialogFragment
|
|||
|
||||
if (selectedStream.getFormat() == MediaFormat.TTML) {
|
||||
psName = Postprocessing.ALGORITHM_TTML_CONVERTER;
|
||||
psArgs = new String[]{
|
||||
psArgs = new String[] {
|
||||
selectedStream.getFormat().getSuffix(),
|
||||
"false" // ignore empty frames
|
||||
};
|
||||
|
@ -1032,17 +1011,22 @@ public class DownloadDialog extends DialogFragment
|
|||
}
|
||||
|
||||
if (secondaryStream == null) {
|
||||
urls = new String[]{
|
||||
selectedStream.getUrl()
|
||||
urls = new String[] {
|
||||
selectedStream.getContent()
|
||||
};
|
||||
recoveryInfo = new MissionRecoveryInfo[]{
|
||||
recoveryInfo = new MissionRecoveryInfo[] {
|
||||
new MissionRecoveryInfo(selectedStream)
|
||||
};
|
||||
} else {
|
||||
urls = new String[]{
|
||||
selectedStream.getUrl(), secondaryStream.getUrl()
|
||||
if (secondaryStream.getDeliveryMethod() != PROGRESSIVE_HTTP) {
|
||||
throw new IllegalArgumentException("Unsupported stream delivery format"
|
||||
+ secondaryStream.getDeliveryMethod());
|
||||
}
|
||||
|
||||
urls = new String[] {
|
||||
selectedStream.getContent(), secondaryStream.getContent()
|
||||
};
|
||||
recoveryInfo = new MissionRecoveryInfo[]{new MissionRecoveryInfo(selectedStream),
|
||||
recoveryInfo = new MissionRecoveryInfo[] {new MissionRecoveryInfo(selectedStream),
|
||||
new MissionRecoveryInfo(secondaryStream)};
|
||||
}
|
||||
|
||||
|
|
|
@ -7,13 +7,13 @@ import kotlinx.parcelize.IgnoredOnParcel
|
|||
import kotlinx.parcelize.Parcelize
|
||||
import org.schabi.newpipe.R
|
||||
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.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException
|
||||
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor.DeobfuscateException
|
||||
import org.schabi.newpipe.ktx.isNetworkRelated
|
||||
import org.schabi.newpipe.util.ServiceHelper
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
|
||||
|
@ -65,7 +65,7 @@ class ErrorInfo(
|
|||
constructor(throwable: Throwable, userAction: UserAction, request: String) :
|
||||
this(throwable, userAction, SERVICE_NONE, request)
|
||||
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?) :
|
||||
this(throwable, userAction, getInfoServiceName(info), request)
|
||||
|
||||
|
@ -73,7 +73,7 @@ class ErrorInfo(
|
|||
constructor(throwable: List<Throwable>, userAction: UserAction, request: String) :
|
||||
this(throwable, userAction, SERVICE_NONE, request)
|
||||
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?) :
|
||||
this(throwable, userAction, getInfoServiceName(info), request)
|
||||
|
||||
|
@ -95,7 +95,7 @@ class ErrorInfo(
|
|||
Array(throwable.size) { i -> getStackTrace(throwable[i]) }
|
||||
|
||||
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
|
||||
private fun getMessageStringId(
|
||||
|
|
|
@ -15,7 +15,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
|||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import org.schabi.newpipe.MainActivity
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||
import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
|
@ -106,7 +105,7 @@ class ErrorPanelHelper(
|
|||
if (!isNullOrEmpty((errorInfo.throwable as AccountTerminatedException).message)) {
|
||||
errorServiceInfoTextView.text = context.resources.getString(
|
||||
R.string.service_provides_reason,
|
||||
NewPipe.getNameOfService(ServiceHelper.getSelectedServiceId(context))
|
||||
ServiceHelper.getSelectedService(context)?.serviceInfo?.name ?: "<unknown>"
|
||||
)
|
||||
errorServiceInfoTextView.isVisible = true
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package org.schabi.newpipe.error
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
@ -10,7 +9,7 @@ import android.os.Build
|
|||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
|
@ -114,13 +113,6 @@ class ErrorUtil {
|
|||
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
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
pendingIntentFlags = pendingIntentFlags or PendingIntent.FLAG_IMMUTABLE
|
||||
|
@ -131,7 +123,13 @@ class ErrorUtil {
|
|||
context,
|
||||
context.getString(R.string.error_report_channel_id)
|
||||
)
|
||||
.setSmallIcon(R.drawable.ic_bug_report)
|
||||
.setSmallIcon(
|
||||
// the vector drawable icon causes crashes on KitKat devices
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
|
||||
R.drawable.ic_bug_report
|
||||
else
|
||||
android.R.drawable.stat_notify_error
|
||||
)
|
||||
.setContentTitle(context.getString(R.string.error_report_notification_title))
|
||||
.setContentText(context.getString(errorInfo.messageStringId))
|
||||
.setAutoCancel(true)
|
||||
|
@ -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
|
||||
Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT)
|
||||
|
|
|
@ -26,10 +26,11 @@ public enum UserAction {
|
|||
DOWNLOAD_OPEN_DIALOG("download open dialog"),
|
||||
DOWNLOAD_POSTPROCESSING("download post-processing"),
|
||||
DOWNLOAD_FAILED("download failed"),
|
||||
NEW_STREAMS_NOTIFICATIONS("new streams notifications"),
|
||||
PREFERENCES_MIGRATION("migration of preferences"),
|
||||
SHARE_TO_NEWPIPE("share to newpipe"),
|
||||
CHECK_FOR_NEW_APP_VERSION("check for new app version");
|
||||
|
||||
CHECK_FOR_NEW_APP_VERSION("check for new app version"),
|
||||
OPEN_INFO_ITEM_DIALOG("open info item dialog");
|
||||
|
||||
private final String message;
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package org.schabi.newpipe.fragments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager;
|
||||
|
@ -10,7 +11,7 @@ import androidx.recyclerview.widget.StaggeredGridLayoutManager;
|
|||
*/
|
||||
public abstract class OnScrollBelowItemsListener extends RecyclerView.OnScrollListener {
|
||||
@Override
|
||||
public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) {
|
||||
public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) {
|
||||
super.onScrolled(recyclerView, dx, dy);
|
||||
if (dy > 0) {
|
||||
int pastVisibleItems = 0;
|
||||
|
|
|
@ -84,7 +84,7 @@ public class DescriptionFragment extends BaseFragment {
|
|||
private void setupDescription() {
|
||||
final Description description = streamInfo.getDescription();
|
||||
if (description == null || isEmpty(description.getContent())
|
||||
|| description == Description.emptyDescription) {
|
||||
|| description == Description.EMPTY_DESCRIPTION) {
|
||||
binding.detailDescriptionView.setVisibility(View.GONE);
|
||||
binding.detailSelectDescriptionButton.setVisibility(View.GONE);
|
||||
return;
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package org.schabi.newpipe.fragments.detail;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
@ -46,6 +48,7 @@ class StackItem implements Serializable {
|
|||
return playQueue;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return getServiceId() + ":" + getUrl() + " > " + getTitle();
|
||||
|
|
|
@ -31,6 +31,7 @@ import android.view.WindowManager;
|
|||
import android.view.animation.DecelerateInterpolator;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.AttrRes;
|
||||
import androidx.annotation.NonNull;
|
||||
|
@ -43,7 +44,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
|||
import androidx.core.content.ContextCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import com.google.android.exoplayer2.ExoPlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
import com.google.android.material.appbar.AppBarLayout;
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior;
|
||||
|
@ -97,6 +98,7 @@ import org.schabi.newpipe.util.Localization;
|
|||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.PermissionHelper;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.StreamTypeUtil;
|
||||
import org.schabi.newpipe.util.SponsorBlockUtils;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
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.playqueue.PlayQueueItem.RECOVERY_UNSET;
|
||||
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
|
||||
import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams;
|
||||
|
||||
public final class VideoDetailFragment
|
||||
extends BaseStateFragment<StreamInfo>
|
||||
|
@ -192,8 +195,6 @@ public final class VideoDetailFragment
|
|||
@Nullable
|
||||
private Disposable videoSegmentsSubscriber = null;
|
||||
|
||||
private List<VideoStream> sortedVideoStreams;
|
||||
private int selectedVideoStreamIndex = -1;
|
||||
private BottomSheetBehavior<FrameLayout> bottomSheetBehavior;
|
||||
private BroadcastReceiver broadcastReceiver;
|
||||
|
||||
|
@ -672,8 +673,7 @@ public final class VideoDetailFragment
|
|||
binding.detailControlsCrashThePlayer.setOnClickListener(
|
||||
v -> VideoDetailPlayerCrasher.onCrashThePlayer(
|
||||
this.getContext(),
|
||||
this.player,
|
||||
getLayoutInflater())
|
||||
this.player)
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1102,9 +1102,6 @@ public final class VideoDetailFragment
|
|||
}
|
||||
|
||||
private void openBackgroundPlayer(final boolean append) {
|
||||
final AudioStream audioStream = currentInfo.getAudioStreams()
|
||||
.get(ListHelper.getDefaultAudioFormat(activity, currentInfo.getAudioStreams()));
|
||||
|
||||
final boolean useExternalAudioPlayer = PreferenceManager
|
||||
.getDefaultSharedPreferences(activity)
|
||||
.getBoolean(activity.getString(R.string.use_external_audio_player_key), false);
|
||||
|
@ -1119,7 +1116,17 @@ public final class VideoDetailFragment
|
|||
if (!useExternalAudioPlayer) {
|
||||
openNormalBackgroundPlayer(append);
|
||||
} 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.detailSecondaryControlPanel.setVisibility(View.GONE);
|
||||
|
||||
sortedVideoStreams = ListHelper.getSortedStreamVideosList(
|
||||
activity,
|
||||
info.getVideoStreams(),
|
||||
info.getVideoOnlyStreams(),
|
||||
false);
|
||||
selectedVideoStreamIndex = ListHelper
|
||||
.getDefaultResolutionIndex(activity, sortedVideoStreams);
|
||||
updateProgressInfo(info);
|
||||
initThumbnailViews(info);
|
||||
showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView,
|
||||
|
@ -1667,8 +1667,8 @@ public final class VideoDetailFragment
|
|||
}
|
||||
}
|
||||
|
||||
binding.detailControlsDownload.setVisibility(info.getStreamType() == StreamType.LIVE_STREAM
|
||||
|| info.getStreamType() == StreamType.AUDIO_LIVE_STREAM ? View.GONE : View.VISIBLE);
|
||||
binding.detailControlsDownload.setVisibility(
|
||||
StreamTypeUtil.isLiveStream(info.getStreamType()) ? View.GONE : View.VISIBLE);
|
||||
binding.detailControlsBackground.setVisibility(info.getAudioStreams().isEmpty()
|
||||
? View.GONE : View.VISIBLE);
|
||||
|
||||
|
@ -1726,18 +1726,12 @@ public final class VideoDetailFragment
|
|||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(videoSegments -> {
|
||||
try {
|
||||
final DownloadDialog downloadDialog = DownloadDialog.newInstance(currentInfo);
|
||||
downloadDialog.setVideoStreams(sortedVideoStreams);
|
||||
downloadDialog.setAudioStreams(currentInfo.getAudioStreams());
|
||||
downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex);
|
||||
downloadDialog.setSubtitleStreams(currentInfo.getSubtitles());
|
||||
final DownloadDialog downloadDialog = new DownloadDialog(activity, currentInfo);
|
||||
downloadDialog.setVideoSegments(videoSegments);
|
||||
downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog");
|
||||
} catch (final Exception e) {
|
||||
ErrorUtil.showSnackbar(activity,
|
||||
new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG,
|
||||
"Showing download dialog",
|
||||
currentInfo));
|
||||
ErrorUtil.showSnackbar(activity, new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG,
|
||||
"Showing download dialog", currentInfo));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -1764,8 +1758,7 @@ public final class VideoDetailFragment
|
|||
binding.detailPositionView.setVisibility(View.GONE);
|
||||
// TODO: Remove this check when separation of concerns is done.
|
||||
// (live streams weren't getting updated because they are mixed)
|
||||
if (!info.getStreamType().equals(StreamType.LIVE_STREAM)
|
||||
&& !info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) {
|
||||
if (!StreamTypeUtil.isLiveStream(info.getStreamType())) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
|
@ -1925,9 +1918,8 @@ public final class VideoDetailFragment
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerError(final ExoPlaybackException error) {
|
||||
if (error.type == ExoPlaybackException.TYPE_SOURCE
|
||||
|| error.type == ExoPlaybackException.TYPE_UNEXPECTED) {
|
||||
public void onPlayerError(final PlaybackException error, final boolean isCatchableException) {
|
||||
if (!isCatchableException) {
|
||||
// Properly exit from fullscreen
|
||||
toggleFullscreenIfInFullscreenMode();
|
||||
hideMainPlayerOnLoadingNewStream();
|
||||
|
@ -2194,25 +2186,52 @@ public final class VideoDetailFragment
|
|||
}
|
||||
|
||||
private void showExternalPlaybackDialog() {
|
||||
if (sortedVideoStreams == null) {
|
||||
if (currentInfo == null) {
|
||||
return;
|
||||
}
|
||||
final CharSequence[] resolutions = new CharSequence[sortedVideoStreams.size()];
|
||||
for (int i = 0; i < sortedVideoStreams.size(); i++) {
|
||||
resolutions[i] = sortedVideoStreams.get(i).getResolution();
|
||||
}
|
||||
final AlertDialog.Builder builder = new AlertDialog.Builder(activity)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setNeutralButton(R.string.open_in_browser, (dialog, i) ->
|
||||
ShareUtils.openUrlInBrowser(requireActivity(), url)
|
||||
|
||||
final AlertDialog.Builder builder = new AlertDialog.Builder(activity);
|
||||
builder.setTitle(R.string.select_quality_external_players);
|
||||
builder.setNeutralButton(R.string.open_in_browser, (dialog, i) ->
|
||||
ShareUtils.openUrlInBrowser(requireActivity(), url));
|
||||
|
||||
final List<VideoStream> videoStreamsForExternalPlayers =
|
||||
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) {
|
||||
builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndex, (dialog, i) -> {
|
||||
dialog.dismiss();
|
||||
startOnExternalPlayer(activity, currentInfo, sortedVideoStreams.get(i));
|
||||
}
|
||||
);
|
||||
|
||||
if (videoStreamsForExternalPlayers.isEmpty()) {
|
||||
builder.setMessage(R.string.no_video_streams_available_for_external_players);
|
||||
builder.setPositiveButton(R.string.ok, null);
|
||||
|
||||
} 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();
|
||||
}
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
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.util.Log;
|
||||
import android.view.ContextThemeWrapper;
|
||||
|
@ -15,6 +19,7 @@ import androidx.appcompat.app.AlertDialog;
|
|||
|
||||
import com.google.android.exoplayer2.C;
|
||||
import com.google.android.exoplayer2.ExoPlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
|
||||
|
@ -51,7 +56,8 @@ public final class VideoDetailPlayerCrasher {
|
|||
exceptionTypes.put(
|
||||
"Source",
|
||||
() -> ExoPlaybackException.createForSource(
|
||||
new IOException(defaultMsg)
|
||||
new IOException(defaultMsg),
|
||||
ERROR_CODE_BEHIND_LIVE_WINDOW
|
||||
)
|
||||
);
|
||||
exceptionTypes.put(
|
||||
|
@ -61,13 +67,16 @@ public final class VideoDetailPlayerCrasher {
|
|||
"Dummy renderer",
|
||||
0,
|
||||
null,
|
||||
C.FORMAT_HANDLED
|
||||
C.FORMAT_HANDLED,
|
||||
/*isRecoverable=*/false,
|
||||
ERROR_CODE_DECODING_FAILED
|
||||
)
|
||||
);
|
||||
exceptionTypes.put(
|
||||
"Unexpected",
|
||||
() -> ExoPlaybackException.createForUnexpected(
|
||||
new RuntimeException(defaultMsg)
|
||||
new RuntimeException(defaultMsg),
|
||||
ERROR_CODE_UNSPECIFIED
|
||||
)
|
||||
);
|
||||
exceptionTypes.put(
|
||||
|
@ -88,8 +97,7 @@ public final class VideoDetailPlayerCrasher {
|
|||
|
||||
public static void onCrashThePlayer(
|
||||
@NonNull final Context context,
|
||||
@Nullable final Player player,
|
||||
@NonNull final LayoutInflater layoutInflater
|
||||
@Nullable final Player player
|
||||
) {
|
||||
if (player == null) {
|
||||
Log.d(TAG, "Player is not available");
|
||||
|
@ -100,16 +108,15 @@ public final class VideoDetailPlayerCrasher {
|
|||
}
|
||||
|
||||
// -- Build the dialog/UI --
|
||||
|
||||
final Context themeWrapperContext = getThemeWrapperContext(context);
|
||||
|
||||
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")
|
||||
.setView(radioGroup)
|
||||
.setView(binding.getRoot())
|
||||
.setCancelable(true)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.create();
|
||||
|
@ -127,11 +134,9 @@ public final class VideoDetailPlayerCrasher {
|
|||
);
|
||||
radioButton.setOnClickListener(v -> {
|
||||
tryCrashPlayerWith(player, entry.getValue().get());
|
||||
if (alertDialog != null) {
|
||||
alertDialog.cancel();
|
||||
}
|
||||
alertDialog.cancel();
|
||||
});
|
||||
radioGroup.addView(radioButton);
|
||||
binding.list.addView(radioButton);
|
||||
}
|
||||
|
||||
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).
|
||||
* It simply supplies a Exception to {@link Player#onPlayerError(ExoPlaybackException)}.
|
||||
* It simply supplies a Exception to {@link Player#onPlayerError(PlaybackException)}.
|
||||
* @param player
|
||||
* @param exception
|
||||
*/
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package org.schabi.newpipe.fragments.list;
|
||||
|
||||
import android.app.Activity;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.res.Configuration;
|
||||
|
@ -25,29 +27,19 @@ import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
|||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.fragments.BaseStateFragment;
|
||||
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
|
||||
import org.schabi.newpipe.info_list.InfoItemDialog;
|
||||
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
|
||||
import org.schabi.newpipe.info_list.InfoListAdapter;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.OnClickGesture;
|
||||
import org.schabi.newpipe.util.StateSaver;
|
||||
import org.schabi.newpipe.util.StreamDialogEntry;
|
||||
import org.schabi.newpipe.views.SuperScrollLayoutManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
|
||||
|
||||
public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
implements ListViewContract<I, N>, StateSaver.WriteRead,
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
@ -268,11 +260,11 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
|||
|
||||
@Override
|
||||
public void held(final StreamInfoItem selectedItem) {
|
||||
showStreamDialog(selectedItem);
|
||||
showInfoItemDialog(selectedItem);
|
||||
}
|
||||
});
|
||||
|
||||
infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<ChannelInfoItem>() {
|
||||
infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<>() {
|
||||
@Override
|
||||
public void selected(final ChannelInfoItem selectedItem) {
|
||||
try {
|
||||
|
@ -288,7 +280,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
|||
}
|
||||
});
|
||||
|
||||
infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture<PlaylistInfoItem>() {
|
||||
infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture<>() {
|
||||
@Override
|
||||
public void selected(final PlaylistInfoItem selectedItem) {
|
||||
try {
|
||||
|
@ -350,7 +342,8 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
|||
itemsList.clearOnScrollListeners();
|
||||
itemsList.addOnScrollListener(new DefaultItemListOnScrolledDownListener() {
|
||||
@Override
|
||||
public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) {
|
||||
public void onScrolled(@NonNull final RecyclerView recyclerView,
|
||||
final int dx, final int dy) {
|
||||
super.onScrolled(recyclerView, dx, dy);
|
||||
|
||||
if (dy != 0) {
|
||||
|
@ -409,55 +402,12 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
|||
}
|
||||
}
|
||||
|
||||
protected void showStreamDialog(final StreamInfoItem item) {
|
||||
final Context context = getContext();
|
||||
final Activity activity = getActivity();
|
||||
if (context == null || context.getResources() == null || activity == null) {
|
||||
return;
|
||||
protected void showInfoItemDialog(final StreamInfoItem item) {
|
||||
try {
|
||||
new InfoItemDialog.Builder(getActivity(), getContext(), this, item).create().show();
|
||||
} catch (final IllegalArgumentException e) {
|
||||
InfoItemDialog.Builder.reportErrorDuringInitialization(e, item);
|
||||
}
|
||||
final List<StreamDialogEntry> entries = new ArrayList<>();
|
||||
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
||||
entries.add(StreamDialogEntry.enqueue);
|
||||
|
||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
||||
entries.add(StreamDialogEntry.enqueue_next);
|
||||
}
|
||||
}
|
||||
|
||||
if (item.getStreamType() == StreamType.AUDIO_STREAM) {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
} else {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.start_here_on_popup,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
}
|
||||
entries.add(StreamDialogEntry.open_in_browser);
|
||||
if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) {
|
||||
entries.add(StreamDialogEntry.play_with_kodi);
|
||||
}
|
||||
|
||||
// show "mark as watched" only when watch history is enabled
|
||||
if (StreamDialogEntry.shouldAddMarkAsWatched(item.getStreamType(), context)) {
|
||||
entries.add(
|
||||
StreamDialogEntry.mark_as_watched
|
||||
);
|
||||
}
|
||||
if (!isNullOrEmpty(item.getUploaderUrl())) {
|
||||
entries.add(StreamDialogEntry.show_channel_details);
|
||||
}
|
||||
|
||||
StreamDialogEntry.setEnabledEntries(entries);
|
||||
|
||||
new InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context),
|
||||
(dialog, which) -> StreamDialogEntry.clickOn(which, this, item)).show();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
|
|
|
@ -9,6 +9,7 @@ import androidx.annotation.NonNull;
|
|||
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.ListInfo;
|
||||
import org.schabi.newpipe.extractor.Page;
|
||||
|
@ -27,8 +28,8 @@ import io.reactivex.rxjava3.core.Single;
|
|||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
extends BaseListFragment<I, ListExtractor.InfoItemsPage> {
|
||||
public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInfo<I>>
|
||||
extends BaseListFragment<L, ListExtractor.InfoItemsPage<I>> {
|
||||
@State
|
||||
protected int serviceId = Constants.NO_SERVICE_ID;
|
||||
@State
|
||||
|
@ -37,7 +38,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
|||
protected String url;
|
||||
|
||||
private final UserAction errorUserAction;
|
||||
protected I currentInfo;
|
||||
protected L currentInfo;
|
||||
protected Page currentNextPage;
|
||||
protected Disposable currentWorker;
|
||||
|
||||
|
@ -97,7 +98,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
|||
@SuppressWarnings("unchecked")
|
||||
public void readFrom(@NonNull final Queue<Object> savedObjects) throws Exception {
|
||||
super.readFrom(savedObjects);
|
||||
currentInfo = (I) savedObjects.poll();
|
||||
currentInfo = (L) savedObjects.poll();
|
||||
currentNextPage = (Page) savedObjects.poll();
|
||||
}
|
||||
|
||||
|
@ -124,7 +125,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
|||
* @param forceLoad allow or disallow the result to come from the cache
|
||||
* @return Rx {@link Single} containing the {@link ListInfo}
|
||||
*/
|
||||
protected abstract Single<I> loadResult(boolean forceLoad);
|
||||
protected abstract Single<L> loadResult(boolean forceLoad);
|
||||
|
||||
@Override
|
||||
public void startLoading(final boolean forceLoad) {
|
||||
|
@ -140,7 +141,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
|||
currentWorker = loadResult(forceLoad)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe((@NonNull I result) -> {
|
||||
.subscribe((@NonNull L result) -> {
|
||||
isLoading.set(false);
|
||||
currentInfo = result;
|
||||
currentNextPage = result.getNextPage();
|
||||
|
@ -157,7 +158,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
|||
*
|
||||
* @return Rx {@link Single} containing the {@link ListExtractor.InfoItemsPage}
|
||||
*/
|
||||
protected abstract Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic();
|
||||
protected abstract Single<ListExtractor.InfoItemsPage<I>> loadMoreItemsLogic();
|
||||
|
||||
@Override
|
||||
protected void loadMoreItems() {
|
||||
|
@ -194,7 +195,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
|||
}
|
||||
|
||||
@Override
|
||||
public void handleNextItems(final ListExtractor.InfoItemsPage result) {
|
||||
public void handleNextItems(final ListExtractor.InfoItemsPage<I> result) {
|
||||
super.handleNextItems(result);
|
||||
|
||||
currentNextPage = result.getNextPage();
|
||||
|
@ -218,7 +219,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
|||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void handleResult(@NonNull final I result) {
|
||||
public void handleResult(@NonNull final L result) {
|
||||
super.handleResult(result);
|
||||
|
||||
name = result.getName();
|
||||
|
|
|
@ -5,6 +5,7 @@ import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
|||
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
@ -22,9 +23,11 @@ import androidx.annotation.Nullable;
|
|||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
import com.jakewharton.rxbinding4.view.RxView;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.databinding.ChannelHeaderBinding;
|
||||
import org.schabi.newpipe.databinding.FragmentChannelBinding;
|
||||
|
@ -39,6 +42,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
|||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.ktx.AnimationType;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||
import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
|
||||
import org.schabi.newpipe.player.MainPlayer.PlayerType;
|
||||
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
|
@ -64,7 +68,7 @@ import io.reactivex.rxjava3.functions.Consumer;
|
|||
import io.reactivex.rxjava3.functions.Function;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, ChannelInfo>
|
||||
implements View.OnClickListener {
|
||||
|
||||
private static final int BUTTON_DEBOUNCE_INTERVAL = 100;
|
||||
|
@ -73,6 +77,8 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
|||
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||
private Disposable subscribeButtonMonitor;
|
||||
|
||||
private boolean channelContentNotSupported = false;
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Views
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
@ -84,6 +90,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
|||
private PlaylistControlBinding playlistControlBinding;
|
||||
|
||||
private MenuItem menuRssButton;
|
||||
private MenuItem menuNotifyButton;
|
||||
|
||||
public static ChannelFragment getInstance(final int serviceId, final String url,
|
||||
final String name) {
|
||||
|
@ -125,6 +132,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
|||
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
|
||||
super.onViewCreated(rootView, savedInstanceState);
|
||||
channelBinding = FragmentChannelBinding.bind(rootView);
|
||||
showContentNotSupportedIfNeeded();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -179,6 +187,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
|||
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||
}
|
||||
menuRssButton = menu.findItem(R.id.menu_item_rss);
|
||||
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -188,6 +197,11 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
|||
case R.id.action_settings:
|
||||
NavigationHelper.openSettings(requireContext());
|
||||
break;
|
||||
case R.id.menu_item_notify:
|
||||
final boolean value = !item.isChecked();
|
||||
item.setEnabled(false);
|
||||
setNotify(value);
|
||||
break;
|
||||
case R.id.menu_item_rss:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.openUrlInBrowser(
|
||||
|
@ -232,15 +246,22 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
|||
.subscribe(getSubscribeUpdateMonitor(info), onError));
|
||||
|
||||
disposables.add(observable
|
||||
// Some updates are very rapid
|
||||
// (for example when calling the updateSubscription(info))
|
||||
// so only update the UI for the latest emission
|
||||
// ("sync" the subscribe button's state)
|
||||
.debounce(100, TimeUnit.MILLISECONDS)
|
||||
.map(List::isEmpty)
|
||||
.distinctUntilChanged()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe((List<SubscriptionEntity> subscriptionEntities) ->
|
||||
updateSubscribeButton(!subscriptionEntities.isEmpty()), onError));
|
||||
.subscribe(isEmpty -> updateSubscribeButton(!isEmpty), onError));
|
||||
|
||||
disposables.add(observable
|
||||
.map(List::isEmpty)
|
||||
.distinctUntilChanged()
|
||||
.skip(1) // channel has just been opened
|
||||
.filter(x -> NotificationHelper.areNewStreamsNotificationsEnabled(requireContext()))
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(isEmpty -> {
|
||||
if (!isEmpty) {
|
||||
showNotifySnackbar();
|
||||
}
|
||||
}, onError));
|
||||
}
|
||||
|
||||
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription,
|
||||
|
@ -320,6 +341,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
|||
info.getAvatarUrl(),
|
||||
info.getDescription(),
|
||||
info.getSubscriberCount());
|
||||
updateNotifyButton(null);
|
||||
subscribeButtonMonitor = monitorSubscribeButton(
|
||||
headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info));
|
||||
} else {
|
||||
|
@ -327,6 +349,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
|||
Log.d(TAG, "Found subscription to this channel!");
|
||||
}
|
||||
final SubscriptionEntity subscription = subscriptionEntities.get(0);
|
||||
updateNotifyButton(subscription);
|
||||
subscribeButtonMonitor = monitorSubscribeButton(
|
||||
headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription));
|
||||
}
|
||||
|
@ -369,12 +392,51 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
|||
AnimationType.LIGHT_SCALE_AND_ALPHA);
|
||||
}
|
||||
|
||||
private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) {
|
||||
if (menuNotifyButton == null) {
|
||||
return;
|
||||
}
|
||||
if (subscription != null) {
|
||||
menuNotifyButton.setEnabled(
|
||||
NotificationHelper.areNewStreamsNotificationsEnabled(requireContext())
|
||||
);
|
||||
menuNotifyButton.setChecked(
|
||||
subscription.getNotificationMode() == NotificationMode.ENABLED
|
||||
);
|
||||
}
|
||||
|
||||
menuNotifyButton.setVisible(subscription != null);
|
||||
}
|
||||
|
||||
private void setNotify(final boolean isEnabled) {
|
||||
disposables.add(
|
||||
subscriptionManager
|
||||
.updateNotificationMode(
|
||||
currentInfo.getServiceId(),
|
||||
currentInfo.getUrl(),
|
||||
isEnabled ? NotificationMode.ENABLED : NotificationMode.DISABLED)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a snackbar with the option to enable notifications on new streams for this channel.
|
||||
*/
|
||||
private void showNotifySnackbar() {
|
||||
Snackbar.make(itemsList, R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.get_notified, v -> setNotify(true))
|
||||
.setActionTextColor(Color.YELLOW)
|
||||
.show();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Load and handle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
||||
protected Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
|
||||
return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPage);
|
||||
}
|
||||
|
||||
|
@ -465,9 +527,12 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
|||
playlistControlBinding.getRoot().setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
channelContentNotSupported = false;
|
||||
for (final Throwable throwable : result.getErrors()) {
|
||||
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.channelKaomoji.setText("(︶︹︺)");
|
||||
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.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfo;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.ktx.ViewUtils;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
|
@ -22,7 +23,7 @@ import org.schabi.newpipe.util.ExtractorHelper;
|
|||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
|
||||
public class CommentsFragment extends BaseListInfoFragment<CommentsInfo> {
|
||||
public class CommentsFragment extends BaseListInfoFragment<CommentsInfoItem, CommentsInfo> {
|
||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||
|
||||
private TextView emptyStateDesc;
|
||||
|
@ -67,7 +68,7 @@ public class CommentsFragment extends BaseListInfoFragment<CommentsInfo> {
|
|||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
||||
protected Single<ListExtractor.InfoItemsPage<CommentsInfoItem>> loadMoreItemsLogic() {
|
||||
return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage);
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
|||
import org.schabi.newpipe.extractor.kiosk.KioskInfo;
|
||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory;
|
||||
import org.schabi.newpipe.extractor.localization.ContentCountry;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.KioskTranslator;
|
||||
|
@ -53,7 +54,7 @@ import io.reactivex.rxjava3.core.Single;
|
|||
* </p>
|
||||
*/
|
||||
|
||||
public class KioskFragment extends BaseListInfoFragment<KioskInfo> {
|
||||
public class KioskFragment extends BaseListInfoFragment<StreamInfoItem, KioskInfo> {
|
||||
@State
|
||||
String kioskId = "";
|
||||
String kioskTranslatedName;
|
||||
|
@ -145,7 +146,7 @@ public class KioskFragment extends BaseListInfoFragment<KioskInfo> {
|
|||
}
|
||||
|
||||
@Override
|
||||
public Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
||||
public Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
|
||||
return ExtractorHelper.getMoreKioskItems(serviceId, url, currentNextPage);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
package org.schabi.newpipe.fragments.list.playlist;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
|
@ -20,11 +18,15 @@ import androidx.annotation.NonNull;
|
|||
import androidx.annotation.Nullable;
|
||||
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.Subscription;
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.R;
|
||||
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.PlaylistHeaderBinding;
|
||||
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.services.youtube.YoutubeParsingHelper;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.info_list.InfoItemDialog;
|
||||
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
|
||||
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
|
||||
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
|
||||
import org.schabi.newpipe.player.MainPlayer.PlayerType;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.StreamDialogEntry;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
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.Disposable;
|
||||
|
||||
public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||
public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, PlaylistInfo> {
|
||||
|
||||
private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG";
|
||||
|
||||
|
@ -140,60 +140,22 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
|||
}
|
||||
|
||||
@Override
|
||||
protected void showStreamDialog(final StreamInfoItem item) {
|
||||
protected void showInfoItemDialog(final StreamInfoItem item) {
|
||||
final Context context = getContext();
|
||||
final Activity activity = getActivity();
|
||||
if (context == null || context.getResources() == null || activity == null) {
|
||||
return;
|
||||
try {
|
||||
final InfoItemDialog.Builder dialogBuilder =
|
||||
new InfoItemDialog.Builder(getActivity(), context, this, item);
|
||||
|
||||
dialogBuilder
|
||||
.setAction(
|
||||
StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND,
|
||||
(f, infoItem) -> NavigationHelper.playOnBackgroundPlayer(
|
||||
context, getPlayQueueStartingAt(infoItem), true))
|
||||
.create()
|
||||
.show();
|
||||
} catch (final IllegalArgumentException e) {
|
||||
InfoItemDialog.Builder.reportErrorDuringInitialization(e, item);
|
||||
}
|
||||
|
||||
final ArrayList<StreamDialogEntry> entries = new ArrayList<>();
|
||||
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
||||
entries.add(StreamDialogEntry.enqueue);
|
||||
|
||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
||||
entries.add(StreamDialogEntry.enqueue_next);
|
||||
}
|
||||
}
|
||||
|
||||
if (item.getStreamType() == StreamType.AUDIO_STREAM) {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
} else {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.start_here_on_popup,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
}
|
||||
entries.add(StreamDialogEntry.open_in_browser);
|
||||
if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) {
|
||||
entries.add(StreamDialogEntry.play_with_kodi);
|
||||
}
|
||||
|
||||
// show "mark as watched" only when watch history is enabled
|
||||
if (StreamDialogEntry.shouldAddMarkAsWatched(item.getStreamType(), context)) {
|
||||
entries.add(
|
||||
StreamDialogEntry.mark_as_watched
|
||||
);
|
||||
}
|
||||
if (!isNullOrEmpty(item.getUploaderUrl())) {
|
||||
entries.add(StreamDialogEntry.show_channel_details);
|
||||
}
|
||||
|
||||
StreamDialogEntry.setEnabledEntries(entries);
|
||||
|
||||
StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItem) ->
|
||||
NavigationHelper.playOnBackgroundPlayer(context,
|
||||
getPlayQueueStartingAt(infoItem), true));
|
||||
|
||||
new InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context),
|
||||
(dialog, which) -> StreamDialogEntry.clickOn(which, this, item)).show();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -249,7 +211,7 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
|||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
||||
protected Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
|
||||
return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPage);
|
||||
}
|
||||
|
||||
|
@ -276,6 +238,17 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
|||
case R.id.menu_item_bookmark:
|
||||
onBookmarkClicked();
|
||||
break;
|
||||
case R.id.menu_item_append_playlist:
|
||||
disposables.add(PlaylistDialog.createCorrespondingDialog(
|
||||
getContext(),
|
||||
getPlayQueue()
|
||||
.getStreams()
|
||||
.stream()
|
||||
.map(StreamEntity::new)
|
||||
.collect(Collectors.toList()),
|
||||
dialog -> dialog.show(getFM(), TAG)
|
||||
));
|
||||
break;
|
||||
default:
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
@ -328,9 +301,12 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
|||
&& (YoutubeParsingHelper.isYoutubeMixId(result.getId())
|
||||
|| YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) {
|
||||
// this is an auto-generated playlist (e.g. Youtube mix), so a radio is shown
|
||||
headerBinding.uploaderAvatarView.setDisableCircularTransformation(true);
|
||||
headerBinding.uploaderAvatarView.setBorderColor(
|
||||
getResources().getColor(R.color.transparent_background_color));
|
||||
final ShapeAppearanceModel model = ShapeAppearanceModel.builder()
|
||||
.setAllCorners(CornerFamily.ROUNDED, 0f)
|
||||
.build(); // this turns the image back into a square
|
||||
headerBinding.uploaderAvatarView.setShapeAppearanceModel(model);
|
||||
headerBinding.uploaderAvatarView.setStrokeColor(AppCompatResources
|
||||
.getColorStateList(requireContext(), R.color.transparent_background_color));
|
||||
headerBinding.uploaderAvatarView.setImageDrawable(
|
||||
AppCompatResources.getDrawable(requireContext(),
|
||||
R.drawable.ic_radio)
|
||||
|
|
|
@ -7,6 +7,7 @@ import android.view.ViewGroup;
|
|||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
|
@ -34,8 +35,10 @@ public class SuggestionListAdapter
|
|||
this.listener = listener;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public SuggestionItemHolder onCreateViewHolder(final ViewGroup parent, final int viewType) {
|
||||
public SuggestionItemHolder onCreateViewHolder(@NonNull final ViewGroup parent,
|
||||
final int viewType) {
|
||||
return new SuggestionItemHolder(LayoutInflater.from(context)
|
||||
.inflate(R.layout.item_search_suggestion, parent, false));
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ import androidx.preference.PreferenceManager;
|
|||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.RelatedItemsHeaderBinding;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
|
@ -26,7 +27,7 @@ import java.util.function.Supplier;
|
|||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
|
||||
public class RelatedItemsFragment extends BaseListInfoFragment<RelatedItemInfo>
|
||||
public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, RelatedItemInfo>
|
||||
implements SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
private static final String INFO_KEY = "related_info_key";
|
||||
|
||||
|
@ -86,7 +87,7 @@ public class RelatedItemsFragment extends BaseListInfoFragment<RelatedItemInfo>
|
|||
}
|
||||
|
||||
@Override
|
||||
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
||||
protected Single<ListExtractor.InfoItemsPage<InfoItem>> loadMoreItemsLogic() {
|
||||
return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,54 +0,0 @@
|
|||
package org.schabi.newpipe.info_list;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.DialogInterface;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
|
||||
public class InfoItemDialog {
|
||||
private final AlertDialog dialog;
|
||||
|
||||
public InfoItemDialog(@NonNull final Activity activity,
|
||||
@NonNull final StreamInfoItem info,
|
||||
@NonNull final String[] commands,
|
||||
@NonNull final DialogInterface.OnClickListener actions) {
|
||||
this(activity, commands, actions, info.getName(), info.getUploaderName());
|
||||
}
|
||||
|
||||
public InfoItemDialog(@NonNull final Activity activity,
|
||||
@NonNull final String[] commands,
|
||||
@NonNull final DialogInterface.OnClickListener actions,
|
||||
@NonNull final String title,
|
||||
@Nullable final String additionalDetail) {
|
||||
|
||||
final View bannerView = View.inflate(activity, R.layout.dialog_title, null);
|
||||
bannerView.setSelected(true);
|
||||
|
||||
final TextView titleView = bannerView.findViewById(R.id.itemTitleView);
|
||||
titleView.setText(title);
|
||||
|
||||
final TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails);
|
||||
if (additionalDetail != null) {
|
||||
detailsView.setText(additionalDetail);
|
||||
detailsView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
detailsView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
dialog = new AlertDialog.Builder(activity)
|
||||
.setCustomTitle(bannerView)
|
||||
.setItems(commands, actions)
|
||||
.create();
|
||||
}
|
||||
|
||||
public void show() {
|
||||
dialog.show();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,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;
|
||||
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
|
@ -11,10 +12,8 @@ import org.schabi.newpipe.local.history.HistoryRecordManager;
|
|||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
|
||||
import de.hdodenhof.circleimageview.CircleImageView;
|
||||
|
||||
public class ChannelMiniInfoItemHolder extends InfoItemHolder {
|
||||
public final CircleImageView itemThumbnailView;
|
||||
public final ImageView itemThumbnailView;
|
||||
public final TextView itemTitleView;
|
||||
private final TextView itemAdditionalDetailView;
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ import android.text.util.Linkify;
|
|||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
|
@ -28,8 +29,6 @@ import org.schabi.newpipe.util.PicassoHelper;
|
|||
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
import de.hdodenhof.circleimageview.CircleImageView;
|
||||
|
||||
public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
||||
private static final String TAG = "CommentsMiniIIHolder";
|
||||
|
||||
|
@ -40,7 +39,7 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
|||
private final int commentVerticalPadding;
|
||||
|
||||
private final RelativeLayout itemRoot;
|
||||
public final CircleImageView itemThumbnailView;
|
||||
public final ImageView itemThumbnailView;
|
||||
private final TextView itemContentView;
|
||||
private final TextView itemLikesCountView;
|
||||
private final TextView itemPublishedTime;
|
||||
|
|
|
@ -11,12 +11,12 @@ import org.schabi.newpipe.R;
|
|||
import org.schabi.newpipe.database.stream.model.StreamStateEntity;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
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.ktx.ViewUtils;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.StreamTypeUtil;
|
||||
import org.schabi.newpipe.views.AnimatedProgressBar;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
@ -70,8 +70,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
|||
} else {
|
||||
itemProgressView.setVisibility(View.GONE);
|
||||
}
|
||||
} else if (item.getStreamType() == StreamType.LIVE_STREAM
|
||||
|| item.getStreamType() == StreamType.AUDIO_LIVE_STREAM) {
|
||||
} else if (StreamTypeUtil.isLiveStream(item.getStreamType())) {
|
||||
itemDurationView.setText(R.string.duration_live);
|
||||
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
|
||||
R.color.live_duration_background_color));
|
||||
|
@ -96,9 +95,10 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
|||
case VIDEO_STREAM:
|
||||
case LIVE_STREAM:
|
||||
case AUDIO_LIVE_STREAM:
|
||||
case POST_LIVE_STREAM:
|
||||
case POST_LIVE_AUDIO_STREAM:
|
||||
enableLongClick(item);
|
||||
break;
|
||||
case FILE:
|
||||
case NONE:
|
||||
default:
|
||||
disableLongClick();
|
||||
|
@ -114,7 +114,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
|||
final StreamStateEntity state
|
||||
= historyRecordManager.loadStreamState(infoItem).blockingGet()[0];
|
||||
if (state != null && item.getDuration() > 0
|
||||
&& item.getStreamType() != StreamType.LIVE_STREAM) {
|
||||
&& !StreamTypeUtil.isLiveStream(item.getStreamType())) {
|
||||
itemProgressView.setMax((int) item.getDuration());
|
||||
if (itemProgressView.getVisibility() == View.VISIBLE) {
|
||||
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
|
||||
|
|
|
@ -300,14 +300,7 @@ private fun View.animateLightSlideAndAlpha(enterOrExit: Boolean, duration: Long,
|
|||
}
|
||||
}
|
||||
|
||||
fun View.slideUp(
|
||||
duration: Long,
|
||||
delay: Long,
|
||||
@FloatRange(from = 0.0, to = 1.0) translationPercent: Float
|
||||
) {
|
||||
slideUp(duration, delay, translationPercent, null)
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun View.slideUp(
|
||||
duration: Long,
|
||||
delay: Long = 0L,
|
||||
|
|
|
@ -228,6 +228,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
|||
return count;
|
||||
}
|
||||
|
||||
@SuppressWarnings("FinalParameters")
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
if (DEBUG) {
|
||||
|
@ -300,6 +301,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
|||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("FinalParameters")
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int position) {
|
||||
if (DEBUG) {
|
||||
|
|
|
@ -33,8 +33,16 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
|
|||
|
||||
private final CompositeDisposable playlistDisposables = new CompositeDisposable();
|
||||
|
||||
public PlaylistAppendDialog(final List<StreamEntity> streamEntities) {
|
||||
super(streamEntities);
|
||||
/**
|
||||
* Create a new instance of {@link PlaylistAppendDialog}.
|
||||
*
|
||||
* @param streamEntities a list of {@link StreamEntity} to be added to playlists
|
||||
* @return a new instance of {@link PlaylistAppendDialog}
|
||||
*/
|
||||
public static PlaylistAppendDialog newInstance(final List<StreamEntity> streamEntities) {
|
||||
final PlaylistAppendDialog dialog = new PlaylistAppendDialog();
|
||||
dialog.setStreamEntities(streamEntities);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
|
@ -103,13 +111,14 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
|
|||
// Helper
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
/** Display create playlist dialog. */
|
||||
public void openCreatePlaylistDialog() {
|
||||
if (getStreamEntities() == null || !isAdded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final PlaylistCreationDialog playlistCreationDialog =
|
||||
new PlaylistCreationDialog(getStreamEntities());
|
||||
PlaylistCreationDialog.newInstance(getStreamEntities());
|
||||
// Move the dismissListener to the new dialog.
|
||||
playlistCreationDialog.setOnDismissListener(this.getOnDismissListener());
|
||||
this.setOnDismissListener(null);
|
||||
|
|
|
@ -21,8 +21,17 @@ import java.util.List;
|
|||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
|
||||
public final class PlaylistCreationDialog extends PlaylistDialog {
|
||||
public PlaylistCreationDialog(final List<StreamEntity> streamEntities) {
|
||||
super(streamEntities);
|
||||
|
||||
/**
|
||||
* Create a new instance of {@link PlaylistCreationDialog}.
|
||||
*
|
||||
* @param streamEntities a list of {@link StreamEntity} to be added to playlists
|
||||
* @return a new instance of {@link PlaylistCreationDialog}
|
||||
*/
|
||||
public static PlaylistCreationDialog newInstance(final List<StreamEntity> streamEntities) {
|
||||
final PlaylistCreationDialog dialog = new PlaylistCreationDialog();
|
||||
dialog.setStreamEntities(streamEntities);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
|
|
|
@ -31,10 +31,6 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
|
|||
|
||||
private org.schabi.newpipe.util.SavedState savedState;
|
||||
|
||||
public PlaylistDialog(final List<StreamEntity> streamEntities) {
|
||||
this.streamEntities = streamEntities;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// LifeCycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
@ -97,7 +93,7 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(final Bundle outState) {
|
||||
public void onSaveInstanceState(@NonNull final Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
if (getActivity() != null) {
|
||||
savedState = StateSaver.tryToSave(getActivity().isChangingConfigurations(),
|
||||
|
@ -120,6 +116,10 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
|
|||
this.onDismissListener = onDismissListener;
|
||||
}
|
||||
|
||||
protected void setStreamEntities(final List<StreamEntity> streamEntities) {
|
||||
this.streamEntities = streamEntities;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Dialog creation
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
@ -143,8 +143,8 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
|
|||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(hasPlaylists ->
|
||||
onExec.accept(hasPlaylists
|
||||
? new PlaylistAppendDialog(streamEntities)
|
||||
: new PlaylistCreationDialog(streamEntities))
|
||||
? PlaylistAppendDialog.newInstance(streamEntities)
|
||||
: PlaylistCreationDialog.newInstance(streamEntities))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
|||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
||||
import org.schabi.newpipe.database.stream.StreamWithState
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||
|
@ -57,6 +58,11 @@ class FeedDatabaseManager(context: Context) {
|
|||
|
||||
fun outdatedSubscriptions(outdatedThreshold: OffsetDateTime) = feedTable.getAllOutdated(outdatedThreshold)
|
||||
|
||||
fun outdatedSubscriptionsWithNotificationMode(
|
||||
outdatedThreshold: OffsetDateTime,
|
||||
@NotificationMode notificationMode: Int
|
||||
) = feedTable.getOutdatedWithNotificationMode(outdatedThreshold, notificationMode)
|
||||
|
||||
fun notLoadedCount(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable<Long> {
|
||||
return when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> feedTable.notLoadedCount()
|
||||
|
@ -72,6 +78,10 @@ class FeedDatabaseManager(context: Context) {
|
|||
fun markAsOutdated(subscriptionId: Long) = feedTable
|
||||
.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null))
|
||||
|
||||
fun doesStreamExist(stream: StreamInfoItem): Boolean {
|
||||
return streamTable.exists(stream.serviceId, stream.url)
|
||||
}
|
||||
|
||||
fun upsertAll(
|
||||
subscriptionId: Long,
|
||||
items: List<StreamInfoItem>,
|
||||
|
|
|
@ -25,7 +25,6 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
|
@ -37,7 +36,6 @@ import android.view.MenuItem
|
|||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.Nullable
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
|
@ -50,7 +48,6 @@ import androidx.recyclerview.widget.GridLayoutManager
|
|||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.xwray.groupie.GroupieAdapter
|
||||
import com.xwray.groupie.Item
|
||||
import com.xwray.groupie.OnAsyncUpdateListener
|
||||
import com.xwray.groupie.OnItemClickListener
|
||||
import com.xwray.groupie.OnItemLongClickListener
|
||||
import icepick.State
|
||||
|
@ -68,25 +65,22 @@ import org.schabi.newpipe.error.UserAction
|
|||
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
|
||||
import org.schabi.newpipe.fragments.BaseStateFragment
|
||||
import org.schabi.newpipe.info_list.InfoItemDialog
|
||||
import org.schabi.newpipe.info_list.dialog.InfoItemDialog
|
||||
import org.schabi.newpipe.ktx.animate
|
||||
import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling
|
||||
import org.schabi.newpipe.ktx.slideUp
|
||||
import org.schabi.newpipe.local.feed.item.StreamItem
|
||||
import org.schabi.newpipe.local.feed.service.FeedLoadService
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder
|
||||
import org.schabi.newpipe.util.DeviceUtils
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.NavigationHelper
|
||||
import org.schabi.newpipe.util.StreamDialogEntry
|
||||
import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams
|
||||
import org.schabi.newpipe.util.ThemeHelper.resolveDrawable
|
||||
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.ArrayList
|
||||
import java.util.function.Consumer
|
||||
|
||||
class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
|
@ -143,7 +137,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||
val factory = FeedViewModel.Factory(requireContext(), groupId)
|
||||
viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java)
|
||||
showPlayedItems = viewModel.getShowPlayedItemsFromPreferences()
|
||||
viewModel.stateLiveData.observe(viewLifecycleOwner, { it?.let(::handleResult) })
|
||||
viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) }
|
||||
|
||||
groupAdapter = GroupieAdapter().apply {
|
||||
setOnItemClickListener(listenerStreamItem)
|
||||
|
@ -356,53 +350,12 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||
feedBinding.loadingProgressBar.max = progressState.maxProgress
|
||||
}
|
||||
|
||||
private fun showStreamDialog(item: StreamInfoItem) {
|
||||
private fun showInfoItemDialog(item: StreamInfoItem) {
|
||||
val context = context
|
||||
val activity: Activity? = getActivity()
|
||||
if (context == null || context.resources == null || activity == null) return
|
||||
|
||||
val entries = ArrayList<StreamDialogEntry>()
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady) {
|
||||
entries.add(StreamDialogEntry.enqueue)
|
||||
|
||||
if (PlayerHolder.getInstance().queueSize > 1) {
|
||||
entries.add(StreamDialogEntry.enqueue_next)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.streamType == StreamType.AUDIO_STREAM) {
|
||||
entries.addAll(
|
||||
listOf(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share,
|
||||
StreamDialogEntry.open_in_browser
|
||||
)
|
||||
)
|
||||
} else {
|
||||
entries.addAll(
|
||||
listOf(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.start_here_on_popup,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share,
|
||||
StreamDialogEntry.open_in_browser
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// show "mark as watched" only when watch history is enabled
|
||||
if (StreamDialogEntry.shouldAddMarkAsWatched(item.streamType, context)) {
|
||||
entries.add(
|
||||
StreamDialogEntry.mark_as_watched
|
||||
)
|
||||
}
|
||||
entries.add(StreamDialogEntry.show_channel_details)
|
||||
|
||||
StreamDialogEntry.setEnabledEntries(entries)
|
||||
InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context)) { _, which ->
|
||||
StreamDialogEntry.clickOn(which, this, item)
|
||||
}.show()
|
||||
InfoItemDialog.Builder(activity, context, this, item).create().show()
|
||||
}
|
||||
|
||||
private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener {
|
||||
|
@ -418,7 +371,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||
|
||||
override fun onItemLongClick(item: Item<*>, view: View): Boolean {
|
||||
if (item is StreamItem && !isRefreshing) {
|
||||
showStreamDialog(item.streamWithState.stream.toStreamInfoItem())
|
||||
showInfoItemDialog(item.streamWithState.stream.toStreamInfoItem())
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
@ -438,14 +391,11 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||
// This need to be saved in a variable as the update occurs async
|
||||
val oldOldestSubscriptionUpdate = oldestSubscriptionUpdate
|
||||
|
||||
groupAdapter.updateAsync(
|
||||
loadedState.items, false,
|
||||
OnAsyncUpdateListener {
|
||||
oldOldestSubscriptionUpdate?.run {
|
||||
highlightNewItemsAfter(oldOldestSubscriptionUpdate)
|
||||
}
|
||||
groupAdapter.updateAsync(loadedState.items, false) {
|
||||
oldOldestSubscriptionUpdate?.run {
|
||||
highlightNewItemsAfter(oldOldestSubscriptionUpdate)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
listState?.run {
|
||||
feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState)
|
||||
|
@ -497,8 +447,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||
}.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
subscriptionEntity ->
|
||||
{ subscriptionEntity ->
|
||||
handleFeedNotAvailable(
|
||||
subscriptionEntity,
|
||||
t.cause,
|
||||
|
@ -629,19 +578,6 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||
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() {
|
||||
tryGetNewItemsLoadedButton()?.clearAnimation()
|
||||
tryGetNewItemsLoadedButton()
|
||||
|
|
|
@ -56,7 +56,7 @@ class FeedViewModel(
|
|||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(Schedulers.io())
|
||||
.map { (event, showPlayedItems, notLoadedCount, oldestUpdate) ->
|
||||
var streamItems = if (event is SuccessResultEvent || event is IdleEvent)
|
||||
val streamItems = if (event is SuccessResultEvent || event is IdleEvent)
|
||||
feedDatabaseManager
|
||||
.getStreams(groupId, showPlayedItems)
|
||||
.blockingGet(arrayListOf())
|
||||
|
|
|
@ -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_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.util.Localization
|
||||
import org.schabi.newpipe.util.PicassoHelper
|
||||
|
@ -109,7 +111,7 @@ data class StreamItem(
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,145 @@
|
|||
package org.schabi.newpipe.local.feed.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.service.FeedUpdateInfo
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.NavigationHelper
|
||||
import org.schabi.newpipe.util.PicassoHelper
|
||||
|
||||
/**
|
||||
* Helper for everything related to show notifications about new streams to the user.
|
||||
*/
|
||||
class NotificationHelper(val context: Context) {
|
||||
|
||||
private val manager = context.getSystemService(
|
||||
Context.NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
|
||||
/**
|
||||
* Show a notification about new streams from a single channel.
|
||||
* Opening the notification will open the corresponding channel page.
|
||||
*/
|
||||
fun displayNewStreamsNotification(data: FeedUpdateInfo) {
|
||||
val newStreams: List<StreamInfoItem> = data.newStreams
|
||||
val summary = context.resources.getQuantityString(
|
||||
R.plurals.new_streams, newStreams.size, newStreams.size
|
||||
)
|
||||
val builder = NotificationCompat.Builder(
|
||||
context,
|
||||
context.getString(R.string.streams_notification_channel_id)
|
||||
)
|
||||
.setContentTitle(Localization.concatenateStrings(data.name, summary))
|
||||
.setContentText(
|
||||
data.listInfo.relatedItems.joinToString(
|
||||
context.getString(R.string.enumeration_comma)
|
||||
) { x -> x.name }
|
||||
)
|
||||
.setNumber(newStreams.size)
|
||||
.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setColor(ContextCompat.getColor(context, R.color.ic_launcher_background))
|
||||
.setColorized(true)
|
||||
.setAutoCancel(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
|
||||
|
||||
// Build style
|
||||
val style = NotificationCompat.InboxStyle()
|
||||
newStreams.forEach { style.addLine(it.name) }
|
||||
style.setSummaryText(summary)
|
||||
style.setBigContentTitle(data.name)
|
||||
builder.setStyle(style)
|
||||
|
||||
// open the channel page when clicking on the notification
|
||||
builder.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
data.pseudoId,
|
||||
NavigationHelper
|
||||
.getChannelIntent(context, data.listInfo.serviceId, data.listInfo.url)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
else
|
||||
0
|
||||
)
|
||||
)
|
||||
|
||||
PicassoHelper.loadNotificationIcon(data.avatarUrl) { bitmap ->
|
||||
bitmap?.let { builder.setLargeIcon(it) } // set only if != null
|
||||
manager.notify(data.pseudoId, builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Check whether notifications are enabled on the device.
|
||||
* Users can disable them via the system settings for a single app.
|
||||
* If this is the case, the app cannot create any notifications
|
||||
* and display them to the user.
|
||||
* <br>
|
||||
* On Android 26 and above, notification channels are used by NewPipe.
|
||||
* These can be configured by the user, too.
|
||||
* The notification channel for new streams is also checked by this method.
|
||||
*
|
||||
* @param context Context
|
||||
* @return <code>true</code> if notifications are allowed and can be displayed;
|
||||
* <code>false</code> otherwise
|
||||
*/
|
||||
fun areNotificationsEnabledOnDevice(context: Context): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channelId = context.getString(R.string.streams_notification_channel_id)
|
||||
val manager = context.getSystemService(
|
||||
Context.NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
val enabled = manager.areNotificationsEnabled()
|
||||
val channel = manager.getNotificationChannel(channelId)
|
||||
val importance = channel?.importance
|
||||
enabled && channel != null && importance != NotificationManager.IMPORTANCE_NONE
|
||||
} else {
|
||||
NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user enabled the notifications for new streams in the app settings.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun areNewStreamsNotificationsEnabled(context: Context): Boolean {
|
||||
return (
|
||||
PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getBoolean(context.getString(R.string.enable_streams_notifications), false) &&
|
||||
areNotificationsEnabledOnDevice(context)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the system's notification settings for NewPipe on Android Oreo (API 26) and later.
|
||||
* Open the system's app settings for NewPipe on previous Android versions.
|
||||
*/
|
||||
fun openNewPipeSystemNotificationSettings(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
} else {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
intent.data = Uri.parse("package:" + context.packageName)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,170 @@
|
|||
package org.schabi.newpipe.local.feed.notifications
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.rxjava3.RxWorker
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.schabi.newpipe.App
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.error.ErrorInfo
|
||||
import org.schabi.newpipe.error.ErrorUtil
|
||||
import org.schabi.newpipe.error.UserAction
|
||||
import org.schabi.newpipe.local.feed.service.FeedLoadManager
|
||||
import org.schabi.newpipe.local.feed.service.FeedLoadService
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/*
|
||||
* Worker which checks for new streams of subscribed channels
|
||||
* in intervals which can be set by the user in the settings.
|
||||
*/
|
||||
class NotificationWorker(
|
||||
appContext: Context,
|
||||
workerParams: WorkerParameters,
|
||||
) : RxWorker(appContext, workerParams) {
|
||||
|
||||
private val notificationHelper by lazy {
|
||||
NotificationHelper(appContext)
|
||||
}
|
||||
private val feedLoadManager = FeedLoadManager(appContext)
|
||||
|
||||
override fun createWork(): Single<Result> = if (areNotificationsEnabled(applicationContext)) {
|
||||
feedLoadManager.startLoading(
|
||||
ignoreOutdatedThreshold = true,
|
||||
groupId = FeedLoadManager.GROUP_NOTIFICATION_ENABLED
|
||||
)
|
||||
.doOnSubscribe { showLoadingFeedForegroundNotification() }
|
||||
.map { feed ->
|
||||
// filter out feedUpdateInfo items (i.e. channels) with nothing new
|
||||
feed.mapNotNull {
|
||||
it.value?.takeIf { feedUpdateInfo ->
|
||||
feedUpdateInfo.newStreams.isNotEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
.observeOn(AndroidSchedulers.mainThread()) // Picasso requires calls from main thread
|
||||
.map { feedUpdateInfoList ->
|
||||
// display notifications for each feedUpdateInfo (i.e. channel)
|
||||
feedUpdateInfoList.forEach { feedUpdateInfo ->
|
||||
notificationHelper.displayNewStreamsNotification(feedUpdateInfo)
|
||||
}
|
||||
return@map Result.success()
|
||||
}
|
||||
.doOnError { throwable ->
|
||||
Log.e(TAG, "Error while displaying streams notifications", throwable)
|
||||
ErrorUtil.createNotification(
|
||||
applicationContext,
|
||||
ErrorInfo(throwable, UserAction.NEW_STREAMS_NOTIFICATIONS, "main worker")
|
||||
)
|
||||
}
|
||||
.onErrorReturnItem(Result.failure())
|
||||
} else {
|
||||
// the user can disable streams notifications in the device's app settings
|
||||
Single.just(Result.success())
|
||||
}
|
||||
|
||||
private fun showLoadingFeedForegroundNotification() {
|
||||
val notification = NotificationCompat.Builder(
|
||||
applicationContext,
|
||||
applicationContext.getString(R.string.notification_channel_id)
|
||||
).setOngoing(true)
|
||||
.setProgress(-1, -1, true)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setContentTitle(applicationContext.getString(R.string.feed_notification_loading))
|
||||
.build()
|
||||
setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification))
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = NotificationWorker::class.java.simpleName
|
||||
private const val WORK_TAG = App.PACKAGE_NAME + "_streams_notifications"
|
||||
|
||||
private fun areNotificationsEnabled(context: Context) =
|
||||
NotificationHelper.areNewStreamsNotificationsEnabled(context) &&
|
||||
NotificationHelper.areNotificationsEnabledOnDevice(context)
|
||||
|
||||
/**
|
||||
* Schedules a task for the [NotificationWorker]
|
||||
* if the (device and in-app) notifications are enabled,
|
||||
* otherwise [cancel]s all scheduled tasks.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun initialize(context: Context) {
|
||||
if (areNotificationsEnabled(context)) {
|
||||
schedule(context)
|
||||
} else {
|
||||
cancel(context)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param context the context to use
|
||||
* @param options configuration options for the scheduler
|
||||
* @param force Force the scheduler to use the new options
|
||||
* by replacing the previously used worker.
|
||||
*/
|
||||
fun schedule(context: Context, options: ScheduleOptions, force: Boolean = false) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(
|
||||
if (options.isRequireNonMeteredNetwork) {
|
||||
NetworkType.UNMETERED
|
||||
} else {
|
||||
NetworkType.CONNECTED
|
||||
}
|
||||
).build()
|
||||
|
||||
val request = PeriodicWorkRequest.Builder(
|
||||
NotificationWorker::class.java,
|
||||
options.interval,
|
||||
TimeUnit.MILLISECONDS
|
||||
).setConstraints(constraints)
|
||||
.addTag(WORK_TAG)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniquePeriodicWork(
|
||||
WORK_TAG,
|
||||
if (force) {
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
} else {
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
},
|
||||
request
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun schedule(context: Context) = schedule(context, ScheduleOptions.from(context))
|
||||
|
||||
/**
|
||||
* Check for new streams immediately
|
||||
*/
|
||||
@JvmStatic
|
||||
fun runNow(context: Context) {
|
||||
val request = OneTimeWorkRequestBuilder<NotificationWorker>()
|
||||
.addTag(WORK_TAG)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueue(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels all current work related to the [NotificationWorker].
|
||||
*/
|
||||
@JvmStatic
|
||||
fun cancel(context: Context) {
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(WORK_TAG)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
package org.schabi.newpipe.local.feed.notifications
|
||||
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
import org.schabi.newpipe.R
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Information for the Scheduler which checks for new streams.
|
||||
* See [NotificationWorker]
|
||||
*/
|
||||
data class ScheduleOptions(
|
||||
val interval: Long,
|
||||
val isRequireNonMeteredNetwork: Boolean
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
fun from(context: Context): ScheduleOptions {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
return ScheduleOptions(
|
||||
interval = TimeUnit.SECONDS.toMillis(
|
||||
preferences.getString(
|
||||
context.getString(R.string.streams_notifications_interval_key),
|
||||
null
|
||||
)?.toLongOrNull() ?: context.getString(
|
||||
R.string.streams_notifications_interval_default
|
||||
).toLong()
|
||||
),
|
||||
isRequireNonMeteredNetwork = preferences.getString(
|
||||
context.getString(R.string.streams_notifications_network_key),
|
||||
context.getString(R.string.streams_notifications_network_default)
|
||||
) == context.getString(R.string.streams_notifications_network_wifi)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,270 @@
|
|||
package org.schabi.newpipe.local.feed.service
|
||||
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Notification
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.functions.Consumer
|
||||
import io.reactivex.rxjava3.processors.PublishProcessor
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.extractor.ListInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class FeedLoadManager(private val context: Context) {
|
||||
|
||||
private val subscriptionManager = SubscriptionManager(context)
|
||||
private val feedDatabaseManager = FeedDatabaseManager(context)
|
||||
|
||||
private val notificationUpdater = PublishProcessor.create<String>()
|
||||
private val currentProgress = AtomicInteger(-1)
|
||||
private val maxProgress = AtomicInteger(-1)
|
||||
private val cancelSignal = AtomicBoolean()
|
||||
private val feedResultsHolder = FeedResultsHolder()
|
||||
|
||||
val notification: Flowable<FeedLoadState> = notificationUpdater.map { description ->
|
||||
FeedLoadState(description, maxProgress.get(), currentProgress.get())
|
||||
}
|
||||
|
||||
/**
|
||||
* Start checking for new streams of a subscription group.
|
||||
* @param groupId The ID of the subscription group to load. When using
|
||||
* [FeedGroupEntity.GROUP_ALL_ID], all subscriptions are loaded. When using
|
||||
* [GROUP_NOTIFICATION_ENABLED], only subscriptions with enabled notifications for new streams
|
||||
* are loaded. Using an id of a group created by the user results in that specific group to be
|
||||
* loaded.
|
||||
* @param ignoreOutdatedThreshold When `false`, only subscriptions which have not been updated
|
||||
* within the `feed_update_threshold` are checked for updates. This threshold can be set by
|
||||
* the user in the app settings. When `true`, all subscriptions are checked for new streams.
|
||||
*/
|
||||
fun startLoading(
|
||||
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
|
||||
ignoreOutdatedThreshold: Boolean = false,
|
||||
): Single<List<Notification<FeedUpdateInfo>>> {
|
||||
val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val useFeedExtractor = defaultSharedPreferences.getBoolean(
|
||||
context.getString(R.string.feed_use_dedicated_fetch_method_key),
|
||||
false
|
||||
)
|
||||
|
||||
val outdatedThreshold = if (ignoreOutdatedThreshold) {
|
||||
OffsetDateTime.now(ZoneOffset.UTC)
|
||||
} else {
|
||||
val thresholdOutdatedSeconds = (
|
||||
defaultSharedPreferences.getString(
|
||||
context.getString(R.string.feed_update_threshold_key),
|
||||
context.getString(R.string.feed_update_threshold_default_value)
|
||||
) ?: context.getString(R.string.feed_update_threshold_default_value)
|
||||
).toInt()
|
||||
OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong())
|
||||
}
|
||||
|
||||
/**
|
||||
* subscriptions which have not been updated within the feed updated threshold
|
||||
*/
|
||||
val outdatedSubscriptions = when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
|
||||
GROUP_NOTIFICATION_ENABLED -> feedDatabaseManager.outdatedSubscriptionsWithNotificationMode(
|
||||
outdatedThreshold, NotificationMode.ENABLED
|
||||
)
|
||||
else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
|
||||
}
|
||||
|
||||
return outdatedSubscriptions
|
||||
.take(1)
|
||||
.doOnNext {
|
||||
currentProgress.set(0)
|
||||
maxProgress.set(it.size)
|
||||
}
|
||||
.filter { it.isNotEmpty() }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext {
|
||||
notificationUpdater.onNext("")
|
||||
broadcastProgress()
|
||||
}
|
||||
.observeOn(Schedulers.io())
|
||||
.flatMap { Flowable.fromIterable(it) }
|
||||
.takeWhile { !cancelSignal.get() }
|
||||
.parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2)
|
||||
.runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
|
||||
.filter { !cancelSignal.get() }
|
||||
.map { subscriptionEntity ->
|
||||
var error: Throwable? = null
|
||||
try {
|
||||
// check for and load new streams
|
||||
// either by using the dedicated feed method or by getting the channel info
|
||||
val listInfo = if (useFeedExtractor) {
|
||||
ExtractorHelper
|
||||
.getFeedInfoFallbackToChannelInfo(
|
||||
subscriptionEntity.serviceId,
|
||||
subscriptionEntity.url
|
||||
)
|
||||
.onErrorReturn {
|
||||
error = it // store error, otherwise wrapped into RuntimeException
|
||||
throw it
|
||||
}
|
||||
.blockingGet()
|
||||
} else {
|
||||
ExtractorHelper
|
||||
.getChannelInfo(
|
||||
subscriptionEntity.serviceId,
|
||||
subscriptionEntity.url,
|
||||
true
|
||||
)
|
||||
.onErrorReturn {
|
||||
error = it // store error, otherwise wrapped into RuntimeException
|
||||
throw it
|
||||
}
|
||||
.blockingGet()
|
||||
} as ListInfo<StreamInfoItem>
|
||||
|
||||
return@map Notification.createOnNext(
|
||||
FeedUpdateInfo(
|
||||
subscriptionEntity,
|
||||
listInfo
|
||||
)
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
if (error == null) {
|
||||
// do this to prevent blockingGet() from wrapping into RuntimeException
|
||||
error = e
|
||||
}
|
||||
|
||||
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
|
||||
val wrapper =
|
||||
FeedLoadService.RequestException(subscriptionEntity.uid, request, error!!)
|
||||
return@map Notification.createOnError<FeedUpdateInfo>(wrapper)
|
||||
}
|
||||
}
|
||||
.sequential()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext(NotificationConsumer())
|
||||
.observeOn(Schedulers.io())
|
||||
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
||||
.doOnNext(DatabaseConsumer())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.toList()
|
||||
.flatMap { x -> postProcessFeed().toSingleDefault(x.flatten()) }
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
cancelSignal.set(true)
|
||||
}
|
||||
|
||||
private fun broadcastProgress() {
|
||||
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(currentProgress.get(), maxProgress.get()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the feed and the stream tables small
|
||||
* to reduce loading times when trying to display the feed.
|
||||
* <br>
|
||||
* Remove streams from the feed which are older than [FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE].
|
||||
* Remove streams from the database which are not linked / used by any table.
|
||||
*/
|
||||
private fun postProcessFeed() = Completable.fromRunnable {
|
||||
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message))
|
||||
feedDatabaseManager.removeOrphansOrOlderStreams()
|
||||
|
||||
FeedEventManager.postEvent(FeedEventManager.Event.SuccessResultEvent(feedResultsHolder.itemsErrors))
|
||||
}.doOnSubscribe {
|
||||
currentProgress.set(-1)
|
||||
maxProgress.set(-1)
|
||||
|
||||
notificationUpdater.onNext(context.getString(R.string.feed_processing_message))
|
||||
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message))
|
||||
}.subscribeOn(Schedulers.io())
|
||||
|
||||
private inner class NotificationConsumer : Consumer<Notification<FeedUpdateInfo>> {
|
||||
override fun accept(item: Notification<FeedUpdateInfo>) {
|
||||
currentProgress.incrementAndGet()
|
||||
notificationUpdater.onNext(item.value?.name.orEmpty())
|
||||
|
||||
broadcastProgress()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class DatabaseConsumer : Consumer<List<Notification<FeedUpdateInfo>>> {
|
||||
|
||||
override fun accept(list: List<Notification<FeedUpdateInfo>>) {
|
||||
feedDatabaseManager.database().runInTransaction {
|
||||
for (notification in list) {
|
||||
when {
|
||||
notification.isOnNext -> {
|
||||
val subscriptionId = notification.value!!.uid
|
||||
val info = notification.value!!.listInfo
|
||||
|
||||
notification.value!!.newStreams = filterNewStreams(
|
||||
notification.value!!.listInfo.relatedItems
|
||||
)
|
||||
|
||||
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
|
||||
subscriptionManager.updateFromInfo(subscriptionId, info)
|
||||
|
||||
if (info.errors.isNotEmpty()) {
|
||||
feedResultsHolder.addErrors(
|
||||
FeedLoadService.RequestException.wrapList(
|
||||
subscriptionId,
|
||||
info
|
||||
)
|
||||
)
|
||||
feedDatabaseManager.markAsOutdated(subscriptionId)
|
||||
}
|
||||
}
|
||||
notification.isOnError -> {
|
||||
val error = notification.error
|
||||
feedResultsHolder.addError(error!!)
|
||||
|
||||
if (error is FeedLoadService.RequestException) {
|
||||
feedDatabaseManager.markAsOutdated(error.subscriptionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterNewStreams(list: List<StreamInfoItem>): List<StreamInfoItem> {
|
||||
return list.filter {
|
||||
!feedDatabaseManager.doesStreamExist(it) &&
|
||||
it.uploadDate != null &&
|
||||
// Streams older than this date are automatically removed from the feed.
|
||||
// Therefore, streams which are not in the database,
|
||||
// but older than this date, are considered old.
|
||||
it.uploadDate!!.offsetDateTime().isAfter(
|
||||
FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Constant used to check for updates of subscriptions with [NotificationMode.ENABLED].
|
||||
*/
|
||||
const val GROUP_NOTIFICATION_ENABLED = -2L
|
||||
|
||||
/**
|
||||
* How many extractions will be running in parallel.
|
||||
*/
|
||||
private const val PARALLEL_EXTRACTIONS = 6
|
||||
|
||||
/**
|
||||
* Number of items to buffer to mass-insert in the database.
|
||||
*/
|
||||
private const val BUFFER_COUNT_BEFORE_INSERT = 20
|
||||
}
|
||||
}
|
|
@ -31,41 +31,24 @@ import android.util.Log
|
|||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Notification
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.functions.Consumer
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.functions.Function
|
||||
import io.reactivex.rxjava3.processors.PublishProcessor
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.reactivestreams.Subscriber
|
||||
import org.reactivestreams.Subscription
|
||||
import org.schabi.newpipe.App
|
||||
import org.schabi.newpipe.MainActivity.DEBUG
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.extractor.ListInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ProgressEvent
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.SuccessResultEvent
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class FeedLoadService : Service() {
|
||||
companion object {
|
||||
private val TAG = FeedLoadService::class.java.simpleName
|
||||
private const val NOTIFICATION_ID = 7293450
|
||||
const val NOTIFICATION_ID = 7293450
|
||||
private const val ACTION_CANCEL = App.PACKAGE_NAME + ".local.feed.service.FeedLoadService.CANCEL"
|
||||
|
||||
/**
|
||||
|
@ -73,27 +56,13 @@ class FeedLoadService : Service() {
|
|||
*/
|
||||
private const val NOTIFICATION_SAMPLING_PERIOD = 1500
|
||||
|
||||
/**
|
||||
* How many extractions will be running in parallel.
|
||||
*/
|
||||
private const val PARALLEL_EXTRACTIONS = 6
|
||||
|
||||
/**
|
||||
* Number of items to buffer to mass-insert in the database.
|
||||
*/
|
||||
private const val BUFFER_COUNT_BEFORE_INSERT = 20
|
||||
|
||||
const val EXTRA_GROUP_ID: String = "FeedLoadService.EXTRA_GROUP_ID"
|
||||
}
|
||||
|
||||
private var loadingSubscription: Subscription? = null
|
||||
private lateinit var subscriptionManager: SubscriptionManager
|
||||
private var loadingDisposable: Disposable? = null
|
||||
private var notificationDisposable: Disposable? = null
|
||||
|
||||
private lateinit var feedDatabaseManager: FeedDatabaseManager
|
||||
private lateinit var feedResultsHolder: ResultsHolder
|
||||
|
||||
private var disposables = CompositeDisposable()
|
||||
private var notificationUpdater = PublishProcessor.create<String>()
|
||||
private lateinit var feedLoadManager: FeedLoadManager
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
// Lifecycle
|
||||
|
@ -101,8 +70,7 @@ class FeedLoadService : Service() {
|
|||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
subscriptionManager = SubscriptionManager(this)
|
||||
feedDatabaseManager = FeedDatabaseManager(this)
|
||||
feedLoadManager = FeedLoadManager(this)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
|
@ -114,40 +82,45 @@ class FeedLoadService : Service() {
|
|||
)
|
||||
}
|
||||
|
||||
if (intent == null || loadingSubscription != null) {
|
||||
if (intent == null || loadingDisposable != null) {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
setupNotification()
|
||||
setupBroadcastReceiver()
|
||||
val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
|
||||
val groupId = intent.getLongExtra(EXTRA_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID)
|
||||
val useFeedExtractor = defaultSharedPreferences
|
||||
.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false)
|
||||
|
||||
val thresholdOutdatedSecondsString = defaultSharedPreferences
|
||||
.getString(getString(R.string.feed_update_threshold_key), getString(R.string.feed_update_threshold_default_value))
|
||||
val thresholdOutdatedSeconds = thresholdOutdatedSecondsString!!.toInt()
|
||||
|
||||
startLoading(groupId, useFeedExtractor, thresholdOutdatedSeconds)
|
||||
|
||||
loadingDisposable = feedLoadManager.startLoading(groupId)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnSubscribe {
|
||||
startForeground(NOTIFICATION_ID, notificationBuilder.build())
|
||||
}
|
||||
.subscribe { _, error ->
|
||||
// There seems to be a bug in the kotlin plugin as it tells you when
|
||||
// building that this can't be null:
|
||||
// "Condition 'error != null' is always 'true'"
|
||||
// However it can indeed be null
|
||||
// The suppression may be removed in further versions
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
if (error != null) {
|
||||
Log.e(TAG, "Error while storing result", error)
|
||||
handleError(error)
|
||||
return@subscribe
|
||||
}
|
||||
stopService()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun disposeAll() {
|
||||
unregisterReceiver(broadcastReceiver)
|
||||
|
||||
loadingSubscription?.cancel()
|
||||
loadingSubscription = null
|
||||
|
||||
disposables.dispose()
|
||||
loadingDisposable?.dispose()
|
||||
notificationDisposable?.dispose()
|
||||
}
|
||||
|
||||
private fun stopService() {
|
||||
disposeAll()
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
|
@ -171,182 +144,6 @@ class FeedLoadService : Service() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun startLoading(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, useFeedExtractor: Boolean, thresholdOutdatedSeconds: Int) {
|
||||
feedResultsHolder = ResultsHolder()
|
||||
|
||||
val outdatedThreshold = OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong())
|
||||
|
||||
val subscriptions = when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
|
||||
else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
|
||||
}
|
||||
|
||||
subscriptions
|
||||
.take(1)
|
||||
.doOnNext {
|
||||
currentProgress.set(0)
|
||||
maxProgress.set(it.size)
|
||||
}
|
||||
.filter { it.isNotEmpty() }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext {
|
||||
startForeground(NOTIFICATION_ID, notificationBuilder.build())
|
||||
updateNotificationProgress(null)
|
||||
broadcastProgress()
|
||||
}
|
||||
.observeOn(Schedulers.io())
|
||||
.flatMap { Flowable.fromIterable(it) }
|
||||
.takeWhile { !cancelSignal.get() }
|
||||
.parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2)
|
||||
.runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
|
||||
.filter { !cancelSignal.get() }
|
||||
.map { subscriptionEntity ->
|
||||
var error: Throwable? = null
|
||||
try {
|
||||
val listInfo = if (useFeedExtractor) {
|
||||
ExtractorHelper
|
||||
.getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url)
|
||||
.onErrorReturn {
|
||||
error = it // store error, otherwise wrapped into RuntimeException
|
||||
throw it
|
||||
}
|
||||
.blockingGet()
|
||||
} else {
|
||||
ExtractorHelper
|
||||
.getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true)
|
||||
.onErrorReturn {
|
||||
error = it // store error, otherwise wrapped into RuntimeException
|
||||
throw it
|
||||
}
|
||||
.blockingGet()
|
||||
} as ListInfo<StreamInfoItem>
|
||||
|
||||
return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo))
|
||||
} catch (e: Throwable) {
|
||||
if (error == null) {
|
||||
// do this to prevent blockingGet() from wrapping into RuntimeException
|
||||
error = e
|
||||
}
|
||||
|
||||
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
|
||||
val wrapper = RequestException(subscriptionEntity.uid, request, error!!)
|
||||
return@map Notification.createOnError<Pair<Long, ListInfo<StreamInfoItem>>>(wrapper)
|
||||
}
|
||||
}
|
||||
.sequential()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext(notificationsConsumer)
|
||||
.observeOn(Schedulers.io())
|
||||
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
||||
.doOnNext(databaseConsumer)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(resultSubscriber)
|
||||
}
|
||||
|
||||
private fun broadcastProgress() {
|
||||
postEvent(ProgressEvent(currentProgress.get(), maxProgress.get()))
|
||||
}
|
||||
|
||||
private val resultSubscriber
|
||||
get() = object : Subscriber<List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>> {
|
||||
|
||||
override fun onSubscribe(s: Subscription) {
|
||||
loadingSubscription = s
|
||||
s.request(java.lang.Long.MAX_VALUE)
|
||||
}
|
||||
|
||||
override fun onNext(notification: List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>) {
|
||||
if (DEBUG) Log.v(TAG, "onNext() → $notification")
|
||||
}
|
||||
|
||||
override fun onError(error: Throwable) {
|
||||
handleError(error)
|
||||
}
|
||||
|
||||
override fun onComplete() {
|
||||
if (maxProgress.get() == 0) {
|
||||
postEvent(FeedEventManager.Event.IdleEvent)
|
||||
stopService()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
currentProgress.set(-1)
|
||||
maxProgress.set(-1)
|
||||
|
||||
notificationUpdater.onNext(getString(R.string.feed_processing_message))
|
||||
postEvent(ProgressEvent(R.string.feed_processing_message))
|
||||
|
||||
disposables.add(
|
||||
Single
|
||||
.fromCallable {
|
||||
feedResultsHolder.ready()
|
||||
|
||||
postEvent(ProgressEvent(R.string.feed_processing_message))
|
||||
feedDatabaseManager.removeOrphansOrOlderStreams()
|
||||
|
||||
postEvent(SuccessResultEvent(feedResultsHolder.itemsErrors))
|
||||
true
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { _, throwable ->
|
||||
// There seems to be a bug in the kotlin plugin as it tells you when
|
||||
// building that this can't be null:
|
||||
// "Condition 'throwable != null' is always 'true'"
|
||||
// However it can indeed be null
|
||||
// The suppression may be removed in further versions
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
if (throwable != null) {
|
||||
Log.e(TAG, "Error while storing result", throwable)
|
||||
handleError(throwable)
|
||||
return@subscribe
|
||||
}
|
||||
stopService()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val databaseConsumer: Consumer<List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>>
|
||||
get() = Consumer {
|
||||
feedDatabaseManager.database().runInTransaction {
|
||||
for (notification in it) {
|
||||
|
||||
if (notification.isOnNext) {
|
||||
val subscriptionId = notification.value!!.first
|
||||
val info = notification.value!!.second
|
||||
|
||||
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
|
||||
subscriptionManager.updateFromInfo(subscriptionId, info)
|
||||
|
||||
if (info.errors.isNotEmpty()) {
|
||||
feedResultsHolder.addErrors(RequestException.wrapList(subscriptionId, info))
|
||||
feedDatabaseManager.markAsOutdated(subscriptionId)
|
||||
}
|
||||
} else if (notification.isOnError) {
|
||||
val error = notification.error!!
|
||||
feedResultsHolder.addError(error)
|
||||
|
||||
if (error is RequestException) {
|
||||
feedDatabaseManager.markAsOutdated(error.subscriptionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val notificationsConsumer: Consumer<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>
|
||||
get() = Consumer { onItemCompleted(it.value?.second?.name) }
|
||||
|
||||
private fun onItemCompleted(updateDescription: String?) {
|
||||
currentProgress.incrementAndGet()
|
||||
notificationUpdater.onNext(updateDescription ?: "")
|
||||
|
||||
broadcastProgress()
|
||||
}
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
// Notification
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
|
@ -354,13 +151,12 @@ class FeedLoadService : Service() {
|
|||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
private lateinit var notificationBuilder: NotificationCompat.Builder
|
||||
|
||||
private var currentProgress = AtomicInteger(-1)
|
||||
private var maxProgress = AtomicInteger(-1)
|
||||
|
||||
private fun createNotification(): NotificationCompat.Builder {
|
||||
val cancelActionIntent = PendingIntent.getBroadcast(
|
||||
this,
|
||||
NOTIFICATION_ID, Intent(ACTION_CANCEL), 0
|
||||
NOTIFICATION_ID,
|
||||
Intent(ACTION_CANCEL),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
|
||||
|
@ -376,33 +172,36 @@ class FeedLoadService : Service() {
|
|||
notificationManager = NotificationManagerCompat.from(this)
|
||||
notificationBuilder = createNotification()
|
||||
|
||||
val throttleAfterFirstEmission = Function { flow: Flowable<String> ->
|
||||
val throttleAfterFirstEmission = Function { flow: Flowable<FeedLoadState> ->
|
||||
flow.take(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS))
|
||||
}
|
||||
|
||||
disposables.add(
|
||||
notificationUpdater
|
||||
.publish(throttleAfterFirstEmission)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(this::updateNotificationProgress)
|
||||
)
|
||||
notificationDisposable = feedLoadManager.notification
|
||||
.publish(throttleAfterFirstEmission)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnTerminate { notificationManager.cancel(NOTIFICATION_ID) }
|
||||
.subscribe(this::updateNotificationProgress)
|
||||
}
|
||||
|
||||
private fun updateNotificationProgress(updateDescription: String?) {
|
||||
notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1)
|
||||
private fun updateNotificationProgress(state: FeedLoadState) {
|
||||
notificationBuilder.setProgress(state.maxProgress, state.currentProgress, state.maxProgress == -1)
|
||||
|
||||
if (maxProgress.get() == -1) {
|
||||
if (state.maxProgress == -1) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null)
|
||||
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
|
||||
notificationBuilder.setContentText(updateDescription)
|
||||
if (state.updateDescription.isNotEmpty()) notificationBuilder.setContentText(state.updateDescription)
|
||||
notificationBuilder.setContentText(state.updateDescription)
|
||||
} else {
|
||||
val progressText = this.currentProgress.toString() + "/" + maxProgress
|
||||
val progressText = state.currentProgress.toString() + "/" + state.maxProgress
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText("$updateDescription ($progressText)")
|
||||
if (state.updateDescription.isNotEmpty()) {
|
||||
notificationBuilder.setContentText("${state.updateDescription} ($progressText)")
|
||||
}
|
||||
} else {
|
||||
notificationBuilder.setContentInfo(progressText)
|
||||
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
|
||||
if (state.updateDescription.isNotEmpty()) {
|
||||
notificationBuilder.setContentText(state.updateDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -414,13 +213,12 @@ class FeedLoadService : Service() {
|
|||
// /////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private lateinit var broadcastReceiver: BroadcastReceiver
|
||||
private val cancelSignal = AtomicBoolean()
|
||||
|
||||
private fun setupBroadcastReceiver() {
|
||||
broadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == ACTION_CANCEL) {
|
||||
cancelSignal.set(true)
|
||||
feedLoadManager.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -435,29 +233,4 @@ class FeedLoadService : Service() {
|
|||
postEvent(ErrorResultEvent(error))
|
||||
stopService()
|
||||
}
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
// Results Holder
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
|
||||
class ResultsHolder {
|
||||
/**
|
||||
* List of errors that may have happen during loading.
|
||||
*/
|
||||
internal lateinit var itemsErrors: List<Throwable>
|
||||
|
||||
private val itemsErrorsHolder: MutableList<Throwable> = ArrayList()
|
||||
|
||||
fun addError(error: Throwable) {
|
||||
itemsErrorsHolder.add(error)
|
||||
}
|
||||
|
||||
fun addErrors(errors: List<Throwable>) {
|
||||
itemsErrorsHolder.addAll(errors)
|
||||
}
|
||||
|
||||
fun ready() {
|
||||
itemsErrors = itemsErrorsHolder.toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
package org.schabi.newpipe.local.feed.service
|
||||
|
||||
data class FeedLoadState(
|
||||
val updateDescription: String,
|
||||
val maxProgress: Int,
|
||||
val currentProgress: Int,
|
||||
)
|
|
@ -0,0 +1,19 @@
|
|||
package org.schabi.newpipe.local.feed.service
|
||||
|
||||
class FeedResultsHolder {
|
||||
/**
|
||||
* List of errors that may have happen during loading.
|
||||
*/
|
||||
val itemsErrors: List<Throwable>
|
||||
get() = itemsErrorsHolder
|
||||
|
||||
private val itemsErrorsHolder: MutableList<Throwable> = ArrayList()
|
||||
|
||||
fun addError(error: Throwable) {
|
||||
itemsErrorsHolder.add(error)
|
||||
}
|
||||
|
||||
fun addErrors(errors: List<Throwable>) {
|
||||
itemsErrorsHolder.addAll(errors)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
package org.schabi.newpipe.local.feed.service
|
||||
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import org.schabi.newpipe.extractor.ListInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
|
||||
data class FeedUpdateInfo(
|
||||
val uid: Long,
|
||||
@NotificationMode
|
||||
val notificationMode: Int,
|
||||
val name: String,
|
||||
val avatarUrl: String,
|
||||
val listInfo: ListInfo<StreamInfoItem>,
|
||||
) {
|
||||
constructor(
|
||||
subscription: SubscriptionEntity,
|
||||
listInfo: ListInfo<StreamInfoItem>,
|
||||
) : this(
|
||||
uid = subscription.uid,
|
||||
notificationMode = subscription.notificationMode,
|
||||
name = subscription.name,
|
||||
avatarUrl = subscription.avatarUrl,
|
||||
listInfo = listInfo,
|
||||
)
|
||||
|
||||
/**
|
||||
* Integer id, can be used as notification id, etc.
|
||||
*/
|
||||
val pseudoId: Int
|
||||
get() = listInfo.url.hashCode()
|
||||
|
||||
lateinit var newStreams: List<StreamInfoItem>
|
||||
}
|
|
@ -84,7 +84,7 @@ public abstract class HistoryEntryAdapter<E, VH extends RecyclerView.ViewHolder>
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onViewRecycled(final VH holder) {
|
||||
public void onViewRecycled(@NonNull final VH holder) {
|
||||
super.onViewRecycled(holder);
|
||||
holder.itemView.setOnClickListener(null);
|
||||
}
|
||||
|
|
|
@ -128,13 +128,11 @@ public class HistoryRecordManager {
|
|||
|
||||
// Add a history entry
|
||||
final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId);
|
||||
if (latestEntry != null) {
|
||||
streamHistoryTable.delete(latestEntry);
|
||||
latestEntry.setAccessDate(currentTime);
|
||||
latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1);
|
||||
return streamHistoryTable.insert(latestEntry);
|
||||
if (latestEntry == null) {
|
||||
// never actually viewed: add history entry but with 0 views
|
||||
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 0));
|
||||
} else {
|
||||
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime));
|
||||
return 0L;
|
||||
}
|
||||
})).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
@ -155,7 +153,8 @@ public class HistoryRecordManager {
|
|||
latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1);
|
||||
return streamHistoryTable.insert(latestEntry);
|
||||
} 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());
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package org.schabi.newpipe.local.history;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.os.Parcelable;
|
||||
|
@ -29,20 +28,16 @@ import org.schabi.newpipe.databinding.StatisticPlaylistControlBinding;
|
|||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.info_list.InfoItemDialog;
|
||||
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
|
||||
import org.schabi.newpipe.local.BaseLocalListFragment;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
||||
import org.schabi.newpipe.settings.HistorySettingsFragment;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.OnClickGesture;
|
||||
import org.schabi.newpipe.util.StreamDialogEntry;
|
||||
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
@ -154,7 +149,7 @@ public class StatisticsPlaylistFragment
|
|||
@Override
|
||||
public void held(final LocalItem selectedItem) {
|
||||
if (selectedItem instanceof StreamStatisticsEntry) {
|
||||
showStreamDialog((StreamStatisticsEntry) selectedItem);
|
||||
showInfoItemDialog((StreamStatisticsEntry) selectedItem);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -328,66 +323,30 @@ public class StatisticsPlaylistFragment
|
|||
return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0));
|
||||
}
|
||||
|
||||
private void showStreamDialog(final StreamStatisticsEntry item) {
|
||||
private void showInfoItemDialog(final StreamStatisticsEntry item) {
|
||||
final Context context = getContext();
|
||||
final Activity activity = getActivity();
|
||||
if (context == null || context.getResources() == null || activity == null) {
|
||||
return;
|
||||
}
|
||||
final StreamInfoItem infoItem = item.toStreamInfoItem();
|
||||
|
||||
final ArrayList<StreamDialogEntry> entries = new ArrayList<>();
|
||||
try {
|
||||
final InfoItemDialog.Builder dialogBuilder =
|
||||
new InfoItemDialog.Builder(getActivity(), context, this, infoItem);
|
||||
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
||||
entries.add(StreamDialogEntry.enqueue);
|
||||
|
||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
||||
entries.add(StreamDialogEntry.enqueue_next);
|
||||
}
|
||||
// set entries in the middle; the others are added automatically
|
||||
dialogBuilder
|
||||
.addEntry(StreamDialogDefaultEntry.DELETE)
|
||||
.setAction(
|
||||
StreamDialogDefaultEntry.DELETE,
|
||||
(f, i) -> deleteEntry(
|
||||
Math.max(itemListAdapter.getItemsList().indexOf(item), 0)))
|
||||
.setAction(
|
||||
StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND,
|
||||
(f, i) -> NavigationHelper.playOnBackgroundPlayer(
|
||||
context, getPlayQueueStartingAt(item), true))
|
||||
.create()
|
||||
.show();
|
||||
} catch (final IllegalArgumentException e) {
|
||||
InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem);
|
||||
}
|
||||
|
||||
if (infoItem.getStreamType() == StreamType.AUDIO_STREAM) {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.delete,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
} else {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.start_here_on_popup,
|
||||
StreamDialogEntry.delete,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
}
|
||||
entries.add(StreamDialogEntry.open_in_browser);
|
||||
if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
|
||||
entries.add(StreamDialogEntry.play_with_kodi);
|
||||
}
|
||||
|
||||
// show "mark as watched" only when watch history is enabled
|
||||
if (StreamDialogEntry.shouldAddMarkAsWatched(
|
||||
item.getStreamEntity().getStreamType(),
|
||||
context
|
||||
)) {
|
||||
entries.add(
|
||||
StreamDialogEntry.mark_as_watched
|
||||
);
|
||||
}
|
||||
entries.add(StreamDialogEntry.show_channel_details);
|
||||
|
||||
StreamDialogEntry.setEnabledEntries(entries);
|
||||
|
||||
StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItemDuplicate) ->
|
||||
NavigationHelper
|
||||
.playOnBackgroundPlayer(context, getPlayQueueStartingAt(item), true));
|
||||
StreamDialogEntry.delete.setCustomAction((fragment, infoItemDuplicate) ->
|
||||
deleteEntry(Math.max(itemListAdapter.getItemsList().indexOf(item), 0)));
|
||||
|
||||
new InfoItemDialog(activity, infoItem, StreamDialogEntry.getCommands(context),
|
||||
(dialog, which) -> StreamDialogEntry.clickOn(which, this, infoItem)).show();
|
||||
}
|
||||
|
||||
private void deleteEntry(final int index) {
|
||||
|
|
|
@ -11,12 +11,12 @@ import androidx.core.content.ContextCompat;
|
|||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.LocalItem;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.ktx.ViewUtils;
|
||||
import org.schabi.newpipe.local.LocalItemBuilder;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
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 java.time.format.DateTimeFormatter;
|
||||
|
@ -59,7 +59,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
|
|||
itemVideoTitleView.setText(item.getStreamEntity().getTitle());
|
||||
itemAdditionalDetailsView.setText(Localization
|
||||
.concatenateStrings(item.getStreamEntity().getUploader(),
|
||||
NewPipe.getNameOfService(item.getStreamEntity().getServiceId())));
|
||||
ServiceHelper.getNameOfServiceById(item.getStreamEntity().getServiceId())));
|
||||
|
||||
if (item.getStreamEntity().getDuration() > 0) {
|
||||
itemDurationView.setText(Localization
|
||||
|
|
|
@ -11,12 +11,12 @@ import androidx.core.content.ContextCompat;
|
|||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.LocalItem;
|
||||
import org.schabi.newpipe.database.stream.StreamStatisticsEntry;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.ktx.ViewUtils;
|
||||
import org.schabi.newpipe.local.LocalItemBuilder;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
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 java.time.format.DateTimeFormatter;
|
||||
|
@ -70,11 +70,12 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
|
|||
|
||||
private String getStreamInfoDetailLine(final StreamStatisticsEntry entry,
|
||||
final DateTimeFormatter dateTimeFormatter) {
|
||||
final String watchCount = Localization
|
||||
.shortViewCount(itemBuilder.getContext(), entry.getWatchCount());
|
||||
final String uploadDate = dateTimeFormatter.format(entry.getLatestAccessDate());
|
||||
final String serviceName = NewPipe.getNameOfService(entry.getStreamEntity().getServiceId());
|
||||
return Localization.concatenateStrings(watchCount, uploadDate, serviceName);
|
||||
return Localization.concatenateStrings(
|
||||
// watchCount
|
||||
Localization.shortViewCount(itemBuilder.getContext(), entry.getWatchCount()),
|
||||
dateTimeFormatter.format(entry.getLatestAccessDate()),
|
||||
// serviceName
|
||||
ServiceHelper.getNameOfServiceById(entry.getStreamEntity().getServiceId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -5,11 +5,11 @@ import android.view.ViewGroup;
|
|||
|
||||
import org.schabi.newpipe.database.LocalItem;
|
||||
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.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
|
||||
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
|
||||
if (!TextUtils.isEmpty(item.getUploader())) {
|
||||
itemUploaderView.setText(Localization.concatenateStrings(item.getUploader(),
|
||||
NewPipe.getNameOfService(item.getServiceId())));
|
||||
ServiceHelper.getNameOfServiceById(item.getServiceId())));
|
||||
} else {
|
||||
itemUploaderView.setText(NewPipe.getNameOfService(item.getServiceId()));
|
||||
itemUploaderView.setText(ServiceHelper.getNameOfServiceById(item.getServiceId()));
|
||||
}
|
||||
|
||||
PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView);
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package org.schabi.newpipe.local.playlist;
|
||||
|
||||
import android.app.Activity;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Bundle;
|
||||
|
@ -38,22 +40,18 @@ import org.schabi.newpipe.databinding.PlaylistControlBinding;
|
|||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.info_list.InfoItemDialog;
|
||||
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
|
||||
import org.schabi.newpipe.local.BaseLocalListFragment;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.player.MainPlayer.PlayerType;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.OnClickGesture;
|
||||
import org.schabi.newpipe.util.StreamDialogEntry;
|
||||
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
|
@ -68,9 +66,6 @@ import io.reactivex.rxjava3.disposables.Disposable;
|
|||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject;
|
||||
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
|
||||
|
||||
public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistStreamEntry>, Void> {
|
||||
// Save the list 10 seconds after the last change occurred
|
||||
private static final long SAVE_DEBOUNCE_MILLIS = 10000;
|
||||
|
@ -182,7 +177,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||
@Override
|
||||
public void held(final LocalItem selectedItem) {
|
||||
if (selectedItem instanceof PlaylistStreamEntry) {
|
||||
showStreamItemDialog((PlaylistStreamEntry) selectedItem);
|
||||
showInfoItemDialog((PlaylistStreamEntry) selectedItem);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -355,7 +350,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||
new AlertDialog.Builder(requireContext())
|
||||
.setMessage(R.string.remove_watched_popup_warning)
|
||||
.setTitle(R.string.remove_watched_popup_title)
|
||||
.setPositiveButton(R.string.yes,
|
||||
.setPositiveButton(R.string.ok,
|
||||
(DialogInterface d, int id) -> removeWatchedStreams(false))
|
||||
.setNeutralButton(
|
||||
R.string.remove_watched_popup_yes_and_partially_watched_videos,
|
||||
|
@ -424,9 +419,11 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||
final PlaylistStreamEntry playlistItem = playlistIter.next();
|
||||
final int indexInHistory = Collections.binarySearch(historyStreamIds,
|
||||
playlistItem.getStreamId());
|
||||
final StreamStateEntity streamStateEntity = streamStatesIter.next();
|
||||
final long duration = playlistItem.toStreamInfoItem().getDuration();
|
||||
|
||||
final boolean hasState = streamStatesIter.next() != null;
|
||||
if (indexInHistory < 0 || hasState) {
|
||||
if (indexInHistory < 0 || (streamStateEntity != null
|
||||
&& !streamStateEntity.isFinished(duration))) {
|
||||
notWatchedItems.add(playlistItem);
|
||||
} else if (!thumbnailVideoRemoved
|
||||
&& playlistManager.getPlaylistThumbnail(playlistId)
|
||||
|
@ -743,70 +740,39 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||
return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0));
|
||||
}
|
||||
|
||||
protected void showStreamItemDialog(final PlaylistStreamEntry item) {
|
||||
final Context context = getContext();
|
||||
final Activity activity = getActivity();
|
||||
if (context == null || context.getResources() == null || activity == null) {
|
||||
return;
|
||||
}
|
||||
protected void showInfoItemDialog(final PlaylistStreamEntry item) {
|
||||
final StreamInfoItem infoItem = item.toStreamInfoItem();
|
||||
|
||||
final ArrayList<StreamDialogEntry> entries = new ArrayList<>();
|
||||
try {
|
||||
final Context context = getContext();
|
||||
final InfoItemDialog.Builder dialogBuilder =
|
||||
new InfoItemDialog.Builder(getActivity(), context, this, infoItem);
|
||||
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
||||
entries.add(StreamDialogEntry.enqueue);
|
||||
|
||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
||||
entries.add(StreamDialogEntry.enqueue_next);
|
||||
}
|
||||
}
|
||||
if (infoItem.getStreamType() == StreamType.AUDIO_STREAM) {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.set_as_playlist_thumbnail,
|
||||
StreamDialogEntry.delete,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
} else {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.start_here_on_popup,
|
||||
StreamDialogEntry.set_as_playlist_thumbnail,
|
||||
StreamDialogEntry.delete,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
}
|
||||
entries.add(StreamDialogEntry.open_in_browser);
|
||||
if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
|
||||
entries.add(StreamDialogEntry.play_with_kodi);
|
||||
}
|
||||
|
||||
// show "mark as watched" only when watch history is enabled
|
||||
if (StreamDialogEntry.shouldAddMarkAsWatched(
|
||||
item.getStreamEntity().getStreamType(),
|
||||
context
|
||||
)) {
|
||||
entries.add(
|
||||
StreamDialogEntry.mark_as_watched
|
||||
// add entries in the middle
|
||||
dialogBuilder.addAllEntries(
|
||||
StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL,
|
||||
StreamDialogDefaultEntry.DELETE
|
||||
);
|
||||
|
||||
// set custom actions
|
||||
// all entries modified below have already been added within the builder
|
||||
dialogBuilder
|
||||
.setAction(
|
||||
StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND,
|
||||
(f, i) -> NavigationHelper.playOnBackgroundPlayer(
|
||||
context, getPlayQueueStartingAt(item), true))
|
||||
.setAction(
|
||||
StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL,
|
||||
(f, i) ->
|
||||
changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl()))
|
||||
.setAction(
|
||||
StreamDialogDefaultEntry.DELETE,
|
||||
(f, i) -> deleteItem(item))
|
||||
.create()
|
||||
.show();
|
||||
} catch (final IllegalArgumentException e) {
|
||||
InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem);
|
||||
}
|
||||
entries.add(StreamDialogEntry.show_channel_details);
|
||||
|
||||
StreamDialogEntry.setEnabledEntries(entries);
|
||||
|
||||
StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItemDuplicate) ->
|
||||
NavigationHelper.playOnBackgroundPlayer(context,
|
||||
getPlayQueueStartingAt(item), true));
|
||||
StreamDialogEntry.set_as_playlist_thumbnail.setCustomAction(
|
||||
(fragment, infoItemDuplicate) ->
|
||||
changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl()));
|
||||
StreamDialogEntry.delete.setCustomAction((fragment, infoItemDuplicate) ->
|
||||
deleteItem(item));
|
||||
|
||||
new InfoItemDialog(activity, infoItem, StreamDialogEntry.getCommands(context),
|
||||
(dialog, which) -> StreamDialogEntry.clickOn(which, this, infoItem)).show();
|
||||
}
|
||||
|
||||
private void setInitialData(final long pid, final String title) {
|
||||
|
|
|
@ -61,7 +61,7 @@ public class ImportConfirmationDialog extends DialogFragment {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(final Bundle outState) {
|
||||
public void onSaveInstanceState(@NonNull final Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
Icepick.saveInstanceState(this, outState);
|
||||
}
|
||||
|
|
|
@ -1,24 +1,24 @@
|
|||
package org.schabi.newpipe.local.subscription
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.SubMenu
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import com.xwray.groupie.Group
|
||||
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.error.ErrorInfo
|
||||
import org.schabi.newpipe.error.UserAction
|
||||
import org.schabi.newpipe.extractor.ServiceList
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
|
||||
import org.schabi.newpipe.fragments.BaseStateFragment
|
||||
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.FeedGroupCardItem
|
||||
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.Companion.PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM
|
||||
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_COMPLETE_ACTION
|
||||
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.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.util.NavigationHelper
|
||||
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.shouldUseGridLayout
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
|
@ -74,12 +73,9 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
|||
private lateinit var subscriptionManager: SubscriptionManager
|
||||
private val disposables: CompositeDisposable = CompositeDisposable()
|
||||
|
||||
private var subscriptionBroadcastReceiver: BroadcastReceiver? = null
|
||||
|
||||
private val groupAdapter = GroupAdapter<GroupieViewHolder<FeedItemCarouselBinding>>()
|
||||
private val feedGroupsSection = Section()
|
||||
private var feedGroupsCarousel: FeedGroupCarouselItem? = null
|
||||
private lateinit var importExportItem: FeedImportExportItem
|
||||
private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem
|
||||
private val subscriptionsSection = Section()
|
||||
|
||||
|
@ -91,12 +87,10 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
|||
@State
|
||||
@JvmField
|
||||
var itemsListState: Parcelable? = null
|
||||
|
||||
@State
|
||||
@JvmField
|
||||
var feedGroupsListState: Parcelable? = null
|
||||
@State
|
||||
@JvmField
|
||||
var importExportItemExpandedState: Boolean? = null
|
||||
|
||||
init {
|
||||
setHasOptionsMenu(true)
|
||||
|
@ -120,20 +114,10 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
|||
return inflater.inflate(R.layout.fragment_subscription, container, false)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
setupBroadcastReceiver()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
itemsListState = binding.itemsList.layoutManager?.onSaveInstanceState()
|
||||
feedGroupsListState = feedGroupsCarousel?.onSaveInstanceState()
|
||||
importExportItemExpandedState = importExportItem.isExpanded
|
||||
|
||||
if (subscriptionBroadcastReceiver != null && activity != null) {
|
||||
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
@ -150,28 +134,61 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
|||
|
||||
activity.supportActionBar?.setDisplayShowTitleEnabled(true)
|
||||
activity.supportActionBar?.setTitle(R.string.tab_subscriptions)
|
||||
|
||||
buildImportExportMenu(menu)
|
||||
}
|
||||
|
||||
private fun setupBroadcastReceiver() {
|
||||
if (activity == null) return
|
||||
private fun buildImportExportMenu(menu: Menu) {
|
||||
// -- Import --
|
||||
val importSubMenu = menu.addSubMenu(R.string.import_from)
|
||||
|
||||
if (subscriptionBroadcastReceiver != null) {
|
||||
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!)
|
||||
}
|
||||
addMenuItemToSubmenu(importSubMenu, R.string.previous_export) { onImportPreviousSelected() }
|
||||
.setIcon(R.drawable.ic_backup)
|
||||
|
||||
val filters = IntentFilter()
|
||||
filters.addAction(EXPORT_COMPLETE_ACTION)
|
||||
filters.addAction(IMPORT_COMPLETE_ACTION)
|
||||
subscriptionBroadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
_binding?.itemsList?.post {
|
||||
importExportItem.isExpanded = false
|
||||
importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS)
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver!!, filters)
|
||||
// -- Export --
|
||||
val exportSubMenu = menu.addSubMenu(R.string.export_to)
|
||||
|
||||
addMenuItemToSubmenu(exportSubMenu, R.string.file) { onExportSelected() }
|
||||
.setIcon(R.drawable.ic_save)
|
||||
}
|
||||
|
||||
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) {
|
||||
|
@ -263,13 +280,14 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
|||
subscriptionsSection.setPlaceholder(EmptyPlaceholderItem())
|
||||
subscriptionsSection.setHideWhenEmpty(true)
|
||||
|
||||
importExportItem = FeedImportExportItem(
|
||||
{ onImportPreviousSelected() },
|
||||
{ onImportFromServiceSelected(it) },
|
||||
{ onExportSelected() },
|
||||
importExportItemExpandedState ?: false
|
||||
groupAdapter.add(
|
||||
Section(
|
||||
HeaderWithMenuItem(
|
||||
getString(R.string.tab_subscriptions)
|
||||
),
|
||||
listOf(subscriptionsSection)
|
||||
)
|
||||
)
|
||||
groupAdapter.add(Section(importExportItem, listOf(subscriptionsSection)))
|
||||
}
|
||||
|
||||
override fun initViews(rootView: View, savedInstanceState: Bundle?) {
|
||||
|
@ -371,13 +389,6 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
|||
subscriptionsSection.update(result.subscriptions)
|
||||
subscriptionsSection.setHideWhenEmpty(false)
|
||||
|
||||
if (result.subscriptions.isEmpty() && importExportItemExpandedState == null) {
|
||||
binding.itemsList.post {
|
||||
importExportItem.isExpanded = true
|
||||
importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS)
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsListState != null) {
|
||||
binding.itemsList.layoutManager?.onRestoreInstanceState(itemsListState)
|
||||
itemsListState = null
|
||||
|
|
|
@ -7,6 +7,8 @@ import io.reactivex.rxjava3.core.Flowable
|
|||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionDAO
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import org.schabi.newpipe.extractor.ListInfo
|
||||
|
@ -14,6 +16,7 @@ import org.schabi.newpipe.extractor.channel.ChannelInfo
|
|||
import org.schabi.newpipe.extractor.feed.FeedInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
|
||||
class SubscriptionManager(context: Context) {
|
||||
private val database = NewPipeDatabase.getInstance(context)
|
||||
|
@ -66,13 +69,33 @@ class SubscriptionManager(context: Context) {
|
|||
}
|
||||
}
|
||||
|
||||
fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable {
|
||||
return subscriptionTable().getSubscription(serviceId, url)
|
||||
.flatMapCompletable { entity: SubscriptionEntity ->
|
||||
Completable.fromAction {
|
||||
entity.notificationMode = mode
|
||||
subscriptionTable().update(entity)
|
||||
}.apply {
|
||||
if (mode != NotificationMode.DISABLED) {
|
||||
// notifications have just been enabled, mark all streams as "old"
|
||||
andThen(rememberAllStreams(entity))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFromInfo(subscriptionId: Long, info: ListInfo<StreamInfoItem>) {
|
||||
val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId)
|
||||
|
||||
if (info is FeedInfo) {
|
||||
subscriptionEntity.name = info.name
|
||||
} else if (info is ChannelInfo) {
|
||||
subscriptionEntity.setData(info.name, info.avatarUrl, info.description, info.subscriberCount)
|
||||
subscriptionEntity.setData(
|
||||
info.name,
|
||||
info.avatarUrl,
|
||||
info.description,
|
||||
info.subscriberCount
|
||||
)
|
||||
}
|
||||
|
||||
subscriptionTable.update(subscriptionEntity)
|
||||
|
@ -94,4 +117,19 @@ class SubscriptionManager(context: Context) {
|
|||
fun deleteSubscription(subscriptionEntity: SubscriptionEntity) {
|
||||
subscriptionTable.delete(subscriptionEntity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the list of videos for the provided channel and saves them in the database, so that
|
||||
* they will be considered as "old"/"already seen" streams and the user will never be notified
|
||||
* about any one of them.
|
||||
*/
|
||||
private fun rememberAllStreams(subscription: SubscriptionEntity): Completable {
|
||||
return ExtractorHelper.getChannelInfo(subscription.serviceId, subscription.url, false)
|
||||
.map { channel -> channel.relatedItems.map { stream -> StreamEntity(stream) } }
|
||||
.flatMapCompletable { entities ->
|
||||
Completable.fromAction {
|
||||
database.streamDAO().upsertAll(entities)
|
||||
}
|
||||
}.onErrorComplete()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
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.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
@ -40,12 +46,6 @@ import java.util.List;
|
|||
|
||||
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 {
|
||||
@State
|
||||
int currentServiceId = Constants.NO_SERVICE_ID;
|
||||
|
@ -89,7 +89,7 @@ public class SubscriptionsImportFragment extends BaseFragment {
|
|||
if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) {
|
||||
ErrorUtil.showSnackbar(activity,
|
||||
new ErrorInfo(new String[]{}, UserAction.SUBSCRIPTION_IMPORT_EXPORT,
|
||||
NewPipe.getNameOfService(currentServiceId),
|
||||
ServiceHelper.getNameOfServiceById(currentServiceId),
|
||||
"Service does not support importing subscriptions",
|
||||
R.string.general_error));
|
||||
activity.finish();
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package org.schabi.newpipe.local.subscription.dialog
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.res.ColorStateList
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.LayoutInflater
|
||||
|
@ -9,7 +8,7 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.os.bundleOf
|
||||
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) {
|
||||
// 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.setHintTextColor(contrastColor.withAlpha(128))
|
||||
ImageViewCompat.setImageTintList(searchLayoutBinding.toolbarSearchClearIcon, contrastColor)
|
||||
|
|
|
@ -8,7 +8,6 @@ import androidx.lifecycle.ViewModelProvider
|
|||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.functions.BiFunction
|
||||
import io.reactivex.rxjava3.processors.BehaviorProcessor
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
|
@ -33,9 +32,8 @@ class FeedGroupDialogViewModel(
|
|||
private var subscriptionsFlowable = Flowable
|
||||
.combineLatest(
|
||||
filterSubscriptions.startWithItem(initialQuery),
|
||||
toggleShowOnlyUngrouped.startWithItem(initialShowOnlyUngrouped),
|
||||
BiFunction { t1: String, t2: Boolean -> Filter(t1, t2) }
|
||||
)
|
||||
toggleShowOnlyUngrouped.startWithItem(initialShowOnlyUngrouped)
|
||||
) { t1: String, t2: Boolean -> Filter(t1, t2) }
|
||||
.distinctUntilChanged()
|
||||
.switchMap { (query, showOnlyUngrouped) ->
|
||||
subscriptionManager.getSubscriptions(groupId, query, showOnlyUngrouped)
|
||||
|
@ -56,9 +54,8 @@ class FeedGroupDialogViewModel(
|
|||
|
||||
private var subscriptionsDisposable = Flowable
|
||||
.combineLatest(
|
||||
subscriptionsFlowable, feedDatabaseManager.subscriptionIdsForGroup(groupId),
|
||||
BiFunction { t1: List<PickerSubscriptionItem>, t2: List<Long> -> t1 to t2.toSet() }
|
||||
)
|
||||
subscriptionsFlowable, feedDatabaseManager.subscriptionIdsForGroup(groupId)
|
||||
) { t1: List<PickerSubscriptionItem>, t2: List<Long> -> t1 to t2.toSet() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe(mutableSubscriptionsLiveData::postValue)
|
||||
|
||||
|
|
|
@ -57,15 +57,12 @@ class FeedGroupReorderDialog : DialogFragment() {
|
|||
|
||||
viewModel = ViewModelProvider(this).get(FeedGroupReorderDialogViewModel::class.java)
|
||||
viewModel.groupsLiveData.observe(viewLifecycleOwner, Observer(::handleGroups))
|
||||
viewModel.dialogEventLiveData.observe(
|
||||
viewLifecycleOwner,
|
||||
Observer {
|
||||
when (it) {
|
||||
ProcessingEvent -> disableInput()
|
||||
SuccessEvent -> dismiss()
|
||||
}
|
||||
viewModel.dialogEventLiveData.observe(viewLifecycleOwner) {
|
||||
when (it) {
|
||||
ProcessingEvent -> disableInput()
|
||||
SuccessEvent -> dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
binding.feedGroupsList.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.feedGroupsList.adapter = groupAdapter
|
||||
|
|
|
@ -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.JsonObject;
|
||||
import com.grack.nanojson.JsonParser;
|
||||
import com.grack.nanojson.JsonSink;
|
||||
import com.grack.nanojson.JsonWriter;
|
||||
|
||||
import org.schabi.newpipe.BuildConfig;
|
||||
|
@ -125,10 +124,11 @@ public final class ImportExportJsonHelper {
|
|||
/**
|
||||
* @see #writeTo(List, OutputStream, ImportExportEventListener)
|
||||
* @param items the list of subscriptions items
|
||||
* @param writer the output {@link JsonSink}
|
||||
* @param writer the output {@link JsonAppendableWriter}
|
||||
* @param eventListener listener for the events generated
|
||||
*/
|
||||
public static void writeTo(final List<SubscriptionItem> items, final JsonSink writer,
|
||||
public static void writeTo(final List<SubscriptionItem> items,
|
||||
final JsonAppendableWriter writer,
|
||||
@Nullable final ImportExportEventListener eventListener) {
|
||||
if (eventListener != null) {
|
||||
eventListener.onSizeReceived(items.size());
|
||||
|
|
|
@ -11,7 +11,6 @@ import androidx.preference.PreferenceManager;
|
|||
import com.google.android.exoplayer2.C;
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
import com.google.android.exoplayer2.Player.EventListener;
|
||||
import com.google.android.exoplayer2.SeekParameters;
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer;
|
||||
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_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 int PROGRESS_LOOP_INTERVAL_MILLIS = 500;
|
||||
|
||||
|
@ -325,6 +324,9 @@ public class LocalPlayer implements EventListener {
|
|||
case "preview":
|
||||
toastText = context
|
||||
.getString(R.string.sponsor_block_skip_preview_toast);
|
||||
case "filler":
|
||||
toastText = context
|
||||
.getString(R.string.sponsor_block_skip_filler_toast);
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
package org.schabi.newpipe.player;
|
||||
|
||||
import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
@ -25,11 +29,9 @@ import androidx.recyclerview.widget.RecyclerView;
|
|||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.databinding.ActivityPlayerQueueControlBinding;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
|
||||
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||
import org.schabi.newpipe.player.event.PlayerEventListener;
|
||||
import org.schabi.newpipe.player.helper.PlaybackParameterDialog;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
|
@ -44,13 +46,6 @@ import org.schabi.newpipe.util.PermissionHelper;
|
|||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import static org.schabi.newpipe.util.SponsorBlockUtils.markSegments;
|
||||
|
||||
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_bg, m);
|
||||
onMaybeMuteChanged();
|
||||
onPlaybackParameterChanged(player.getPlaybackParameters());
|
||||
// to avoid null reference
|
||||
if (player != null) {
|
||||
onPlaybackParameterChanged(player.getPlaybackParameters());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -133,7 +131,7 @@ public final class PlayQueueActivity extends AppCompatActivity
|
|||
NavigationHelper.openSettings(this);
|
||||
return true;
|
||||
case R.id.action_append_playlist:
|
||||
appendAllToPlaylist();
|
||||
player.onAddToPlaylistClicked(getSupportFragmentManager());
|
||||
return true;
|
||||
case R.id.action_playback_speed:
|
||||
openPlaybackParameterDialog();
|
||||
|
@ -453,24 +451,6 @@ public final class PlayQueueActivity extends AppCompatActivity
|
|||
seeking = false;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Playlist append
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private void appendAllToPlaylist() {
|
||||
if (player != null && player.getPlayQueue() != null) {
|
||||
openPlaylistAppendDialog(player.getPlayQueue().getStreams());
|
||||
}
|
||||
}
|
||||
|
||||
private void openPlaylistAppendDialog(final List<PlayQueueItem> playQueueItems) {
|
||||
PlaylistDialog.createCorrespondingDialog(
|
||||
getApplicationContext(),
|
||||
playQueueItems.stream().map(StreamEntity::new).collect(Collectors.toList()),
|
||||
dialog -> dialog.show(getSupportFragmentManager(), TAG)
|
||||
);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Binding Service Listener
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
@ -634,7 +614,6 @@ public final class PlayQueueActivity extends AppCompatActivity
|
|||
|
||||
//2) Icon change accordingly to current App Theme
|
||||
// using rootView.getContext() because getApplicationContext() didn't work
|
||||
final Context context = queueControlBinding.getRoot().getContext();
|
||||
item.setIcon(player.isMuted() ? R.drawable.ic_volume_off : R.drawable.ic_volume_up);
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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) {
|
||||
// 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);
|
||||
final float currentProgressPercent = (float) player
|
||||
.getVolumeProgressBar().getProgress() / player.getMaxGestureLength();
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package org.schabi.newpipe.player.event;
|
||||
|
||||
import com.google.android.exoplayer2.ExoPlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
|
||||
public interface PlayerServiceEventListener extends PlayerEventListener {
|
||||
void onFullscreenStateChanged(boolean fullscreen);
|
||||
|
@ -9,7 +9,7 @@ public interface PlayerServiceEventListener extends PlayerEventListener {
|
|||
|
||||
void onMoreOptionsLongClicked();
|
||||
|
||||
void onPlayerError(ExoPlaybackException error);
|
||||
void onPlayerError(PlaybackException error, boolean isCatchableException);
|
||||
|
||||
void hideSystemUiIfNeeded();
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ import androidx.core.content.ContextCompat;
|
|||
import androidx.media.AudioFocusRequestCompat;
|
||||
import androidx.media.AudioManagerCompat;
|
||||
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer;
|
||||
import com.google.android.exoplayer2.ExoPlayer;
|
||||
import com.google.android.exoplayer2.analytics.AnalyticsListener;
|
||||
|
||||
public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, AnalyticsListener {
|
||||
|
@ -27,14 +27,14 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, An
|
|||
private static final int FOCUS_GAIN_TYPE = AudioManagerCompat.AUDIOFOCUS_GAIN;
|
||||
private static final int STREAM_TYPE = AudioManager.STREAM_MUSIC;
|
||||
|
||||
private final SimpleExoPlayer player;
|
||||
private final ExoPlayer player;
|
||||
private final Context context;
|
||||
private final AudioManager audioManager;
|
||||
|
||||
private final AudioFocusRequestCompat request;
|
||||
|
||||
public AudioReactor(@NonNull final Context context,
|
||||
@NonNull final SimpleExoPlayer player) {
|
||||
@NonNull final ExoPlayer player) {
|
||||
this.player = player;
|
||||
this.context = context;
|
||||
this.audioManager = ContextCompat.getSystemService(context, AudioManager.class);
|
||||
|
@ -149,7 +149,8 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, An
|
|||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onAudioSessionIdChanged(final EventTime eventTime, final int audioSessionId) {
|
||||
public void onAudioSessionIdChanged(@NonNull final EventTime eventTime,
|
||||
final int audioSessionId) {
|
||||
notifyAudioSessionUpdate(true, audioSessionId);
|
||||
}
|
||||
private void notifyAudioSessionUpdate(final boolean active, final int audioSessionId) {
|
||||
|
|
|
@ -1,93 +1,46 @@
|
|||
package org.schabi.newpipe.player.helper;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.android.exoplayer2.database.ExoDatabaseProvider;
|
||||
import com.google.android.exoplayer2.upstream.DataSource;
|
||||
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.TransferListener;
|
||||
import com.google.android.exoplayer2.upstream.cache.CacheDataSink;
|
||||
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 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 static final String TAG = "CacheFactory";
|
||||
private final Context context;
|
||||
private final TransferListener transferListener;
|
||||
private final DataSource.Factory upstreamDataSourceFactory;
|
||||
private final SimpleCache cache;
|
||||
|
||||
private static final String CACHE_FOLDER_NAME = "exoplayer";
|
||||
private static final int CACHE_FLAGS = CacheDataSource.FLAG_BLOCK_ON_CACHE
|
||||
| CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR;
|
||||
|
||||
private final DefaultDataSourceFactory dataSourceFactory;
|
||||
private final File cacheDir;
|
||||
private final long maxFileSize;
|
||||
|
||||
// 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));
|
||||
}
|
||||
CacheFactory(final Context context,
|
||||
final TransferListener transferListener,
|
||||
final SimpleCache cache,
|
||||
final DataSource.Factory upstreamDataSourceFactory) {
|
||||
this.context = context;
|
||||
this.transferListener = transferListener;
|
||||
this.cache = cache;
|
||||
this.upstreamDataSourceFactory = upstreamDataSourceFactory;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
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 CacheDataSink dataSink = new CacheDataSink(cache, maxFileSize);
|
||||
|
||||
final CacheDataSink dataSink
|
||||
= new CacheDataSink(cache, PlayerHelper.getPreferredFileSize());
|
||||
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