Merge branch 'sponsorblock' into preview-category

This commit is contained in:
poly 2022-07-09 11:20:34 -06:00 committed by GitHub
commit 51c862498f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
713 changed files with 15220 additions and 7448 deletions

View file

@ -68,7 +68,7 @@ The [checkStyle](https://github.com/checkstyle/checkstyle) plugin verifies that
- Go to `File -> Settings -> Plugins`, search for `checkstyle` and install `CheckStyle-IDEA`. - Go to `File -> Settings -> Plugins`, search for `checkstyle` and install `CheckStyle-IDEA`.
- Go to `File -> Settings -> Tools -> Checkstyle`. - Go to `File -> Settings -> Tools -> Checkstyle`.
- Add NewPipe's configuration file by clicking the `+` in the right toolbar of the "Configuration File" list. - Add NewPipe's configuration file by clicking the `+` in the right toolbar of the "Configuration File" list.
- Under the "Use a local Checkstyle file" bullet, click on `Browse` and pick the file named `checkstyle.xml` in the project's root folder. - Under the "Use a local Checkstyle file" bullet, click on `Browse` and, enter `checkstyle` folder under the project's root path and pick the file named `checkstyle.xml`.
- Enable "Store relative to project location" so that moving the directory around does not create issues. - Enable "Store relative to project location" so that moving the directory around does not create issues.
- Insert a description in the top bar, then click `Next` and then `Finish`. - Insert a description in the top bar, then click `Next` and then `Finish`.
- Activate the configuration file you just added by enabling the checkbox on the left. - Activate the configuration file you just added by enabling the checkbox on the left.

View file

@ -6,6 +6,7 @@ on:
branches: branches:
- dev - dev
- master - master
- release/**
paths-ignore: paths-ignore:
- 'README.md' - 'README.md'
- 'doc/**' - 'doc/**'
@ -31,7 +32,7 @@ jobs:
build-and-test-jvm: build-and-test-jvm:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: gradle/wrapper-validation-action@v1 - uses: gradle/wrapper-validation-action@v1
- name: create and checkout branch - name: create and checkout branch
@ -40,7 +41,7 @@ jobs:
run: git checkout -B ${{ github.head_ref }} run: git checkout -B ${{ github.head_ref }}
- name: set up JDK 11 - name: set up JDK 11
uses: actions/setup-java@v2 uses: actions/setup-java@v3
with: with:
java-version: 11 java-version: 11
distribution: "temurin" distribution: "temurin"
@ -50,7 +51,7 @@ jobs:
run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint
- name: Upload APK - name: Upload APK
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
with: with:
name: app name: app
path: app/build/outputs/apk/debug/*.apk path: app/build/outputs/apk/debug/*.apk
@ -64,10 +65,10 @@ jobs:
# api-level 19 is min sdk, but throws errors related to desugaring # api-level 19 is min sdk, but throws errors related to desugaring
api-level: [ 21, 29 ] api-level: [ 21, 29 ]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- name: set up JDK 11 - name: set up JDK 11
uses: actions/setup-java@v2 uses: actions/setup-java@v3
with: with:
java-version: 11 java-version: 11
distribution: "temurin" distribution: "temurin"
@ -82,7 +83,7 @@ jobs:
script: ./gradlew connectedCheck --stacktrace script: ./gradlew connectedCheck --stacktrace
- name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553 - name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
if: failure() if: failure()
with: with:
name: android-test-report-api${{ matrix.api-level }} name: android-test-report-api${{ matrix.api-level }}
@ -91,19 +92,19 @@ jobs:
sonar: sonar:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
with: with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Set up JDK 11 - name: Set up JDK 11
uses: actions/setup-java@v2 uses: actions/setup-java@v3
with: with:
java-version: 11 # Sonar requires JDK 11 java-version: 11 # Sonar requires JDK 11
distribution: "temurin" distribution: "temurin"
cache: 'gradle' cache: 'gradle'
- name: Cache SonarCloud packages - name: Cache SonarCloud packages
uses: actions/cache@v2 uses: actions/cache@v3
with: with:
path: ~/.sonar/cache path: ~/.sonar/cache
key: ${{ runner.os }}-sonar key: ${{ runner.os }}-sonar

View file

@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v3
- uses: actions/setup-node@v2 - uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: 16
@ -21,7 +21,7 @@ jobs:
run: npm i probe-image-size@7.2.3 --ignore-scripts run: npm i probe-image-size@7.2.3 --ignore-scripts
- name: Minimize simple images - name: Minimize simple images
uses: actions/github-script@v5 uses: actions/github-script@v6
timeout-minutes: 3 timeout-minutes: 3
with: with:
script: | script: |

View file

@ -1,7 +1,7 @@
GNU GENERAL PUBLIC LICENSE GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007 Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed. of this license document, but changing it is not allowed.
@ -645,7 +645,7 @@ the "copyright" line and a pointer to where the full notice is found.
GNU General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail. Also add information on how to contact you by electronic and paper mail.
@ -664,11 +664,11 @@ might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school, You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary. if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>. <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>. <https://www.gnu.org/licenses/why-not-lgpl.html>.

View file

@ -1,5 +1,5 @@
# NewPipe x SponsorBlock # NewPipe x SponsorBlock x Return YouTube Dislike
A fork of [NewPipe](https://github.com/TeamNewPipe/NewPipe) with [SponsorBlock](https://sponsor.ajay.app/) functionality. A fork of [NewPipe](https://github.com/TeamNewPipe/NewPipe) with [SponsorBlock](https://sponsor.ajay.app/) and [Return YouTube Dislike](https://returnyoutubedislike.com/) functionality.
![01](.github/images/preview01.gif) ![01](.github/images/preview01.gif)
![02](.github/images/preview02.gif) ![02](.github/images/preview02.gif)
@ -12,9 +12,9 @@ The implementation is still a bit basic but it generally works pretty well.
Builds will be uploaded in the [Releases](https://github.com/polymorphicshade/NewPipe/releases) section. Please download the APK from the newest release and install it on your device. Builds will be uploaded in the [Releases](https://github.com/polymorphicshade/NewPipe/releases) section. Please download the APK from the newest release and install it on your device.
## Why isn't this in upstream NewPipe? ## Why isn't this in upstream NewPipe?
[The developer team](https://github.com/TeamNewPipe) behind the official NewPipe decided that they do not want to include this kind of functionality in their app. See https://newpipe.schabi.org/blog/pinned/newpipe-and-online-advertising/ and https://github.com/TeamNewPipe/NewPipe/pull/3205 for more information and discussion. [The developer team](https://github.com/TeamNewPipe) behind the official NewPipe decided that they do not want to include these kinds of functionality in their app. See https://newpipe.schabi.org/blog/pinned/newpipe-and-online-advertising/, https://github.com/TeamNewPipe/NewPipe/pull/3205, and https://github.com/TeamNewPipe/NewPipe/issues/7469 for more information and discussion.
We obviously disagree but we respect their decision and continue to offer SponsorBlock in NewPipe via this fork. We obviously disagree but we respect their decision and continue to offer SponsorBlock and Return YouTube Dislike in NewPipe via this fork.
## Bugs ## Bugs
Please do not report bugs encountered while using this fork to the upstream developers. Either try to reproduce the bug in vanilla NewPipe and then report it (preferred) or [create a bug report in our repo](https://github.com/polymorphicshade/NewPipe/issues/new?assignees=&labels=bug&template=bug_report.md). Please do not report bugs encountered while using this fork to the upstream developers. Either try to reproduce the bug in vanilla NewPipe and then report it (preferred) or [create a bug report in our repo](https://github.com/polymorphicshade/NewPipe/issues/new?assignees=&labels=bug&template=bug_report.md).

View file

@ -9,15 +9,15 @@ plugins {
android { android {
compileSdk 31 compileSdk 31
buildToolsVersion '30.0.3' buildToolsVersion '31.0.0'
defaultConfig { defaultConfig {
applicationId "org.polymorphicshade.newpipe" applicationId "org.polymorphicshade.newpipe"
resValue "string", "app_name", "NewPipe SponsorBlock" resValue "string", "app_name", "NewPipe SponsorBlock"
minSdk 19 minSdk 19
targetSdk 29 targetSdk 29
versionCode 984 versionCode 987
versionName "0.22.1" versionName "0.23.1"
multiDexEnabled true multiDexEnabled true
@ -98,15 +98,16 @@ android {
} }
ext { ext {
checkstyleVersion = '9.2.1' checkstyleVersion = '10.0'
androidxLifecycleVersion = '2.3.1' androidxLifecycleVersion = '2.3.1'
androidxRoomVersion = '2.3.0' androidxRoomVersion = '2.4.2'
androidxWorkVersion = '2.7.1'
icepickVersion = '3.2.0' icepickVersion = '3.2.0'
exoPlayerVersion = '2.14.2' exoPlayerVersion = '2.17.1'
googleAutoServiceVersion = '1.0.1' googleAutoServiceVersion = '1.0.1'
groupieVersion = '2.10.0' groupieVersion = '2.10.1'
markwonVersion = '4.6.2' markwonVersion = '4.6.2'
leakCanaryVersion = '2.5' leakCanaryVersion = '2.5'
@ -121,7 +122,7 @@ configurations {
} }
checkstyle { checkstyle {
getConfigDirectory().set(rootProject.file(".")) getConfigDirectory().set(rootProject.file("checkstyle"))
ignoreFailures false ignoreFailures false
showViolations true showViolations true
toolVersion = checkstyleVersion toolVersion = checkstyleVersion
@ -189,11 +190,11 @@ dependencies {
// name and the commit hash with the commit hash of the (pushed) commit you want to test // name and the commit hash with the commit hash of the (pushed) commit you want to test
// This works thanks to JitPack: https://jitpack.io/ // This works thanks to JitPack: https://jitpack.io/
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751' implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.21.14' implementation 'com.github.TeamNewPipe:NewPipeExtractor:5219a705bab539cf8c6624d0cec216e76e85f0b1'
/** Checkstyle **/ /** Checkstyle **/
checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}" checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}"
ktlint 'com.pinterest:ktlint:0.43.2' ktlint 'com.pinterest:ktlint:0.44.0'
/** Kotlin **/ /** Kotlin **/
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}"
@ -201,16 +202,16 @@ dependencies {
/** AndroidX **/ /** AndroidX **/
implementation 'androidx.appcompat:appcompat:1.3.1' implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.2' implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
implementation 'androidx.core:core-ktx:1.6.0' implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.documentfile:documentfile:1.0.1' implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'androidx.fragment:fragment-ktx:1.3.6' implementation 'androidx.fragment:fragment-ktx:1.3.6'
implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}" implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}"
implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}" implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}"
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0' implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
implementation 'androidx.media:media:1.4.3' implementation 'androidx.media:media:1.5.0'
implementation 'androidx.multidex:multidex:2.0.1' implementation 'androidx.multidex:multidex:2.0.1'
implementation 'androidx.preference:preference:1.1.1' implementation 'androidx.preference:preference:1.2.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation "androidx.room:room-runtime:${androidxRoomVersion}" implementation "androidx.room:room-runtime:${androidxRoomVersion}"
implementation "androidx.room:room-rxjava3:${androidxRoomVersion}" implementation "androidx.room:room-rxjava3:${androidxRoomVersion}"
@ -220,7 +221,9 @@ dependencies {
// https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01 // https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.webkit:webkit:1.4.0' implementation 'androidx.webkit:webkit:1.4.0'
implementation 'com.google.android.material:material:1.4.0' implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}"
implementation "androidx.work:work-rxjava3:${androidxWorkVersion}"
implementation 'com.google.android.material:material:1.5.0'
/** Third-party libraries **/ /** Third-party libraries **/
// Instance state boilerplate elimination // Instance state boilerplate elimination
@ -246,8 +249,6 @@ dependencies {
implementation "com.github.lisawray.groupie:groupie:${groupieVersion}" implementation "com.github.lisawray.groupie:groupie:${groupieVersion}"
implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}" implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}"
// Circular ImageView
implementation "de.hdodenhof:circleimageview:3.1.0"
// Image loading // Image loading
//noinspection GradleDependency --> 2.8 is the last version, not 2.71828! //noinspection GradleDependency --> 2.8 is the last version, not 2.71828!
implementation "com.squareup.picasso:picasso:2.8" implementation "com.squareup.picasso:picasso:2.8"
@ -260,7 +261,7 @@ dependencies {
implementation "com.nononsenseapps:filepicker:4.2.1" implementation "com.nononsenseapps:filepicker:4.2.1"
// Crash reporting // Crash reporting
implementation "ch.acra:acra-core:5.8.4" implementation "ch.acra:acra-core:5.9.3"
// Properly restarting // Properly restarting
implementation 'com.jakewharton:process-phoenix:2.1.2' implementation 'com.jakewharton:process-phoenix:2.1.2'

View file

@ -51,3 +51,6 @@
private void writeObject(java.io.ObjectOutputStream); private void writeObject(java.io.ObjectOutputStream);
private void readObject(java.io.ObjectInputStream); private void readObject(java.io.ObjectInputStream);
} }
# for some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml)
-keep class org.schabi.newpipe.settings.notifications.** { *; }

View 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')"
]
}
}

View file

@ -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
}
}

View file

@ -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)
}
}
}

View file

@ -386,9 +386,6 @@
<service <service
android:name=".RouterActivity$FetcherService" android:name=".RouterActivity$FetcherService"
android:exported="false" /> android:exported="false" />
<service
android:name=".CheckForNewAppVersion"
android:exported="false" />
<!-- opting out of sending metrics to Google in Android System WebView --> <!-- opting out of sending metrics to Google in Android System WebView -->
<meta-data android:name="android.webkit.WebView.MetricsOptOut" android:value="true" /> <meta-data android:name="android.webkit.WebView.MetricsOptOut" android:value="true" />

View file

@ -27,7 +27,7 @@ import org.schabi.newpipe.util.StateSaver;
import java.io.IOException; import java.io.IOException;
import java.io.InterruptedIOException; import java.io.InterruptedIOException;
import java.net.SocketException; import java.net.SocketException;
import java.util.Arrays; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@ -205,7 +205,7 @@ public class App extends MultiDexApplication {
return; return;
} }
final CoreConfigurationBuilder acraConfig = new CoreConfigurationBuilder(this) final CoreConfigurationBuilder acraConfig = new CoreConfigurationBuilder()
.withBuildConfigClass(BuildConfig.class); .withBuildConfigClass(BuildConfig.class);
ACRA.init(this, acraConfig); ACRA.init(this, acraConfig);
} }
@ -213,37 +213,44 @@ public class App extends MultiDexApplication {
private void initNotificationChannels() { private void initNotificationChannels() {
// Keep the importance below DEFAULT to avoid making noise on every notification update for // Keep the importance below DEFAULT to avoid making noise on every notification update for
// the main and update channels // the main and update channels
final NotificationChannelCompat mainChannel = new NotificationChannelCompat final List<NotificationChannelCompat> notificationChannelCompats = new ArrayList<>();
notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.notification_channel_id), .Builder(getString(R.string.notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW) NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.notification_channel_name)) .setName(getString(R.string.notification_channel_name))
.setDescription(getString(R.string.notification_channel_description)) .setDescription(getString(R.string.notification_channel_description))
.build(); .build());
final NotificationChannelCompat appUpdateChannel = new NotificationChannelCompat notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.app_update_notification_channel_id), .Builder(getString(R.string.app_update_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW) NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.app_update_notification_channel_name)) .setName(getString(R.string.app_update_notification_channel_name))
.setDescription(getString(R.string.app_update_notification_channel_description)) .setDescription(getString(R.string.app_update_notification_channel_description))
.build(); .build());
final NotificationChannelCompat hashChannel = new NotificationChannelCompat notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.hash_channel_id), .Builder(getString(R.string.hash_channel_id),
NotificationManagerCompat.IMPORTANCE_HIGH) NotificationManagerCompat.IMPORTANCE_HIGH)
.setName(getString(R.string.hash_channel_name)) .setName(getString(R.string.hash_channel_name))
.setDescription(getString(R.string.hash_channel_description)) .setDescription(getString(R.string.hash_channel_description))
.build(); .build());
final NotificationChannelCompat errorReportChannel = new NotificationChannelCompat notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.error_report_channel_id), .Builder(getString(R.string.error_report_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW) NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.error_report_channel_name)) .setName(getString(R.string.error_report_channel_name))
.setDescription(getString(R.string.error_report_channel_description)) .setDescription(getString(R.string.error_report_channel_description))
.build(); .build());
notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.streams_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(getString(R.string.streams_notification_channel_name))
.setDescription(getString(R.string.streams_notification_channel_description))
.build());
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this); final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
notificationManager.createNotificationChannelsCompat(Arrays.asList(mainChannel, notificationManager.createNotificationChannelsCompat(notificationChannelCompats);
appUpdateChannel, hashChannel, errorReportChannel));
} }
protected boolean isDisposedRxExceptionsReported() { protected boolean isDisposedRxExceptionsReported() {

View file

@ -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);
}
});
}
}

View file

@ -43,7 +43,7 @@ import static org.schabi.newpipe.MainActivity.DEBUG;
public final class DownloaderImpl extends Downloader { public final class DownloaderImpl extends Downloader {
public static final String USER_AGENT public static final String USER_AGENT
= "Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.0"; = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0";
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY
= "youtube_restricted_mode_key"; = "youtube_restricted_mode_key";
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000"; public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000";

View file

@ -29,7 +29,7 @@ import org.schabi.newpipe.util.VideoSegment;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
public class LocalPlayerActivity extends AppCompatActivity implements Player.EventListener, public class LocalPlayerActivity extends AppCompatActivity implements Player.Listener,
LocalPlayerListener, PlaybackParameterDialog.Callback { LocalPlayerListener, PlaybackParameterDialog.Callback {
private LocalPlayer localPlayer; private LocalPlayer localPlayer;
private PlayerView playerView; private PlayerView playerView;

View file

@ -20,7 +20,6 @@
package org.schabi.newpipe; package org.schabi.newpipe;
import static org.schabi.newpipe.CheckForNewAppVersion.checkNewVersion;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.content.BroadcastReceiver; import android.content.BroadcastReceiver;
@ -72,6 +71,7 @@ import org.schabi.newpipe.fragments.BackPressable;
import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.MainFragment;
import org.schabi.newpipe.fragments.detail.VideoDetailFragment; import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
import org.schabi.newpipe.fragments.list.search.SearchFragment; import org.schabi.newpipe.fragments.list.search.SearchFragment;
import org.schabi.newpipe.local.feed.notifications.NotificationWorker;
import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.event.OnKeyDownListener; import org.schabi.newpipe.player.event.OnKeyDownListener;
import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.helper.PlayerHolder;
@ -159,20 +159,28 @@ public class MainActivity extends AppCompatActivity {
} catch (final Exception e) { } catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(this, "Setting up drawer", e); ErrorUtil.showUiErrorSnackbar(this, "Setting up drawer", e);
} }
if (DeviceUtils.isTv(this)) { if (DeviceUtils.isTv(this)) {
FocusOverlayView.setupFocusObserver(this); FocusOverlayView.setupFocusObserver(this);
} }
openMiniPlayerUponPlayerStarted(); openMiniPlayerUponPlayerStarted();
// Schedule worker for checking for new streams and creating corresponding notifications
// if this is enabled by the user.
NotificationWorker.initialize(this);
} }
@Override @Override
protected void onPostCreate(final Bundle savedInstanceState) { protected void onPostCreate(final Bundle savedInstanceState) {
super.onPostCreate(savedInstanceState); super.onPostCreate(savedInstanceState);
// Start the service which is checking all conditions
// and eventually searching for a new version. final App app = App.getApp();
// The service searching for a new NewPipe version must not be started in background. final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
checkNewVersion();
if (prefs.getBoolean(app.getString(R.string.update_app_key), true)) {
// Start the worker which is checking all conditions
// and eventually searching for a new version.
NewVersionWorker.enqueueNewVersionCheckingWork(app);
}
} }
private void setupDrawer() throws ExtractionException { private void setupDrawer() throws ExtractionException {
@ -221,7 +229,7 @@ public class MainActivity extends AppCompatActivity {
drawerLayoutBinding.navigation.getMenu() drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_tabs_group, kioskId, 0, KioskTranslator .add(R.id.menu_tabs_group, kioskId, 0, KioskTranslator
.getTranslatedKioskName(ks, this)) .getTranslatedKioskName(ks, this))
.setIcon(KioskTranslator.getKioskIcon(ks, this)); .setIcon(KioskTranslator.getKioskIcon(ks));
kioskId++; kioskId++;
} }
@ -713,7 +721,7 @@ public class MainActivity extends AppCompatActivity {
if (toggle != null) { if (toggle != null) {
toggle.syncState(); toggle.syncState();
toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> mainBinding.getRoot() toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> mainBinding.getRoot()
.openDrawer(GravityCompat.START)); .open());
mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED); mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED);
} }
} else { } else {

View file

@ -1,5 +1,11 @@
package org.schabi.newpipe; package org.schabi.newpipe;
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
@ -8,11 +14,6 @@ import androidx.room.Room;
import org.schabi.newpipe.database.AppDatabase; import org.schabi.newpipe.database.AppDatabase;
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
public final class NewPipeDatabase { public final class NewPipeDatabase {
private static volatile AppDatabase databaseInstance; private static volatile AppDatabase databaseInstance;
@ -23,7 +24,7 @@ public final class NewPipeDatabase {
private static AppDatabase getDatabase(final Context context) { private static AppDatabase getDatabase(final Context context) {
return Room return Room
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME) .databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
.build(); .build();
} }

View file

@ -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()
}
}

View 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)
}
}
}

View file

@ -14,7 +14,7 @@ import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.SaveUploaderUrlHelper; import org.schabi.newpipe.util.SparseItemUtil;
import java.util.Collections; import java.util.Collections;
@ -62,7 +62,8 @@ public final class QueueItemMenuUtil {
return true; return true;
case R.id.menu_item_channel_details: case R.id.menu_item_channel_details:
SaveUploaderUrlHelper.saveUploaderUrlIfNeeded(context, item, SparseItemUtil.fetchUploaderUrlIfSparse(context, item.getServiceId(),
item.getUrl(), item.getUploaderUrl(),
// An intent must be used here. // An intent must be used here.
// Opening with FragmentManager transactions is not working, // Opening with FragmentManager transactions is not working,
// as PlayQueueActivity doesn't use fragments. // as PlayQueueActivity doesn't use fragments.

View file

@ -24,12 +24,12 @@ import android.widget.Toast;
import androidx.annotation.DrawableRes; import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat;
import androidx.core.app.ServiceCompat; import androidx.core.app.ServiceCompat;
import androidx.core.widget.TextViewCompat;
import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentManager;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
@ -58,7 +58,6 @@ import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException;
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException; import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.MainPlayer; import org.schabi.newpipe.player.MainPlayer;
@ -71,7 +70,7 @@ import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.PermissionHelper;
import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.ThemeHelper;
@ -127,8 +126,10 @@ public class RouterActivity extends AppCompatActivity {
} }
} }
ThemeHelper.setDayNightMode(this);
setTheme(ThemeHelper.isLightThemeSelected(this) setTheme(ThemeHelper.isLightThemeSelected(this)
? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark); ? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark);
Localization.assureCorrectAppLanguage(this);
} }
@Override @Override
@ -257,80 +258,122 @@ public class RouterActivity extends AppCompatActivity {
protected void onSuccess() { protected void onSuccess() {
final SharedPreferences preferences = PreferenceManager final SharedPreferences preferences = PreferenceManager
.getDefaultSharedPreferences(this); .getDefaultSharedPreferences(this);
final String selectedChoiceKey = preferences
.getString(getString(R.string.preferred_open_action_key),
getString(R.string.preferred_open_action_default));
final String showInfoKey = getString(R.string.show_info_key); final ChoiceAvailabilityChecker choiceChecker = new ChoiceAvailabilityChecker(
final String videoPlayerKey = getString(R.string.video_player_key); getChoicesForService(currentService, currentLinkType),
final String backgroundPlayerKey = getString(R.string.background_player_key); preferences.getString(getString(R.string.preferred_open_action_key),
final String popupPlayerKey = getString(R.string.popup_player_key); getString(R.string.preferred_open_action_default)));
final String downloadKey = getString(R.string.download_key);
final String alwaysAskKey = getString(R.string.always_ask_open_action_key);
if (selectedChoiceKey.equals(alwaysAskKey)) { // Check for non-player related choices
final List<AdapterChoiceItem> choices if (choiceChecker.isAvailableAndSelected(
= getChoicesForService(currentService, currentLinkType); R.string.show_info_key,
R.string.download_key,
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( final boolean isExtVideoEnabled = preferences.getBoolean(
getString(R.string.use_external_video_player_key), false); getString(R.string.use_external_video_player_key), false);
final boolean isExtAudioEnabled = preferences.getBoolean( final boolean isExtAudioEnabled = preferences.getBoolean(
getString(R.string.use_external_audio_player_key), false); getString(R.string.use_external_audio_player_key), false);
final boolean isVideoPlayerSelected = selectedChoiceKey.equals(videoPlayerKey) final boolean isVideoPlayerSelected =
|| selectedChoiceKey.equals(popupPlayerKey); selectedChoice.equals(getString(R.string.video_player_key))
final boolean isAudioPlayerSelected = selectedChoiceKey.equals(backgroundPlayerKey); || selectedChoice.equals(getString(R.string.popup_player_key));
final boolean isAudioPlayerSelected =
selectedChoice.equals(getString(R.string.background_player_key));
if (currentLinkType != LinkType.STREAM) { if (currentLinkType != LinkType.STREAM
if (isExtAudioEnabled && isAudioPlayerSelected && ((isExtAudioEnabled && isAudioPlayerSelected)
|| isExtVideoEnabled && isVideoPlayerSelected) { || (isExtVideoEnabled && isVideoPlayerSelected))
Toast.makeText(this, R.string.external_player_unsupported_link_type, ) {
Toast.LENGTH_LONG).show(); Toast.makeText(this, R.string.external_player_unsupported_link_type,
handleChoice(showInfoKey); Toast.LENGTH_LONG).show();
return; handleChoice(getString(R.string.show_info_key));
} return;
} }
final List<StreamingService.ServiceInfo.MediaCapability> capabilities final List<StreamingService.ServiceInfo.MediaCapability> capabilities =
= currentService.getServiceInfo().getMediaCapabilities(); currentService.getServiceInfo().getMediaCapabilities();
boolean serviceSupportsChoice = false; // Check if the service supports the choice
if (isVideoPlayerSelected) { if ((isVideoPlayerSelected && capabilities.contains(VIDEO))
serviceSupportsChoice = capabilities.contains(VIDEO); || (isAudioPlayerSelected && capabilities.contains(AUDIO))) {
} else if (selectedChoiceKey.equals(backgroundPlayerKey)) { handleChoice(selectedChoice);
serviceSupportsChoice = capabilities.contains(AUDIO);
}
if (serviceSupportsChoice) {
handleChoice(selectedChoiceKey);
} else { } else {
handleChoice(showInfoKey); handleChoice(getString(R.string.show_info_key));
} }
return;
}
// Default / Ask always
final List<AdapterChoiceItem> availableChoices = choiceChecker.getAvailableChoices();
switch (availableChoices.size()) {
case 1:
handleChoice(availableChoices.get(0).key);
break;
case 0:
handleChoice(getString(R.string.show_info_key));
break;
default:
showDialog(availableChoices);
break;
}
}
/**
* This is a helper class for checking if the choices are available and/or selected.
*/
class ChoiceAvailabilityChecker {
private final List<AdapterChoiceItem> availableChoices;
private final String selectedChoiceKey;
ChoiceAvailabilityChecker(
@NonNull final List<AdapterChoiceItem> availableChoices,
@NonNull final String selectedChoiceKey) {
this.availableChoices = availableChoices;
this.selectedChoiceKey = selectedChoiceKey;
}
public List<AdapterChoiceItem> getAvailableChoices() {
return availableChoices;
}
public String getSelectedChoiceKey() {
return selectedChoiceKey;
}
public boolean isAvailableAndSelected(@StringRes final int... wantedKeys) {
return Arrays.stream(wantedKeys).anyMatch(this::isAvailableAndSelected);
}
public boolean isAvailableAndSelected(@StringRes final int wantedKey) {
final String wanted = getString(wantedKey);
// Check if the wanted option is selected
if (!selectedChoiceKey.equals(wanted)) {
return false;
}
// Check if it's available
return availableChoices.stream().anyMatch(item -> wanted.equals(item.key));
} }
} }
private void showDialog(final List<AdapterChoiceItem> choices) { private void showDialog(final List<AdapterChoiceItem> choices) {
final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
final Context themeWrapperContext = getThemeWrapperContext();
final LayoutInflater inflater = LayoutInflater.from(themeWrapperContext); final Context themeWrapperContext = getThemeWrapperContext();
final RadioGroup radioGroup = SingleChoiceDialogViewBinding.inflate(getLayoutInflater()) final LayoutInflater layoutInflater = LayoutInflater.from(themeWrapperContext);
.list;
final SingleChoiceDialogViewBinding binding =
SingleChoiceDialogViewBinding.inflate(layoutInflater);
final RadioGroup radioGroup = binding.list;
final DialogInterface.OnClickListener dialogButtonsClickListener = (dialog, which) -> { final DialogInterface.OnClickListener dialogButtonsClickListener = (dialog, which) -> {
final int indexOfChild = radioGroup.indexOfChild( final int indexOfChild = radioGroup.indexOfChild(
@ -349,21 +392,19 @@ public class RouterActivity extends AppCompatActivity {
alertDialogChoice = new AlertDialog.Builder(themeWrapperContext) alertDialogChoice = new AlertDialog.Builder(themeWrapperContext)
.setTitle(R.string.preferred_open_action_share_menu_title) .setTitle(R.string.preferred_open_action_share_menu_title)
.setView(radioGroup) .setView(binding.getRoot())
.setCancelable(true) .setCancelable(true)
.setNegativeButton(R.string.just_once, dialogButtonsClickListener) .setNegativeButton(R.string.just_once, dialogButtonsClickListener)
.setPositiveButton(R.string.always, dialogButtonsClickListener) .setPositiveButton(R.string.always, dialogButtonsClickListener)
.setOnDismissListener((dialog) -> { .setOnDismissListener(dialog -> {
if (!selectionIsDownload && !selectionIsAddToPlaylist) { if (!selectionIsDownload && !selectionIsAddToPlaylist) {
finish(); finish();
} }
}) })
.create(); .create();
//noinspection CodeBlock2Expr alertDialogChoice.setOnShowListener(dialog -> setDialogButtonsState(
alertDialogChoice.setOnShowListener(dialog -> { alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1));
setDialogButtonsState(alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1);
});
radioGroup.setOnCheckedChangeListener((group, checkedId) -> radioGroup.setOnCheckedChangeListener((group, checkedId) ->
setDialogButtonsState(alertDialogChoice, true)); setDialogButtonsState(alertDialogChoice, true));
@ -383,9 +424,10 @@ public class RouterActivity extends AppCompatActivity {
int id = 12345; int id = 12345;
for (final AdapterChoiceItem item : choices) { for (final AdapterChoiceItem item : choices) {
final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater).getRoot(); final RadioButton radioButton = ListRadioIconItemBinding.inflate(layoutInflater)
.getRoot();
radioButton.setText(item.description); radioButton.setText(item.description);
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(radioButton, radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(
AppCompatResources.getDrawable(themeWrapperContext, item.icon), AppCompatResources.getDrawable(themeWrapperContext, item.icon),
null, null, null); null, null, null);
radioButton.setChecked(false); radioButton.setChecked(false);
@ -425,87 +467,64 @@ public class RouterActivity extends AppCompatActivity {
private List<AdapterChoiceItem> getChoicesForService(final StreamingService service, private List<AdapterChoiceItem> getChoicesForService(final StreamingService service,
final LinkType linkType) { final LinkType linkType) {
final Context context = getThemeWrapperContext();
final 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( final AdapterChoiceItem showInfo = new AdapterChoiceItem(
getString(R.string.show_info_key), getString(R.string.show_info), getString(R.string.show_info_key), getString(R.string.show_info),
R.drawable.ic_info_outline); R.drawable.ic_info_outline);
final AdapterChoiceItem popupPlayer = new AdapterChoiceItem( final AdapterChoiceItem videoPlayer = new AdapterChoiceItem(
getString(R.string.popup_player_key), getString(R.string.popup_player), getString(R.string.video_player_key), getString(R.string.video_player),
R.drawable.ic_picture_in_picture); R.drawable.ic_play_arrow);
final AdapterChoiceItem backgroundPlayer = new AdapterChoiceItem( final AdapterChoiceItem backgroundPlayer = new AdapterChoiceItem(
getString(R.string.background_player_key), getString(R.string.background_player), getString(R.string.background_player_key), getString(R.string.background_player),
R.drawable.ic_headset); R.drawable.ic_headset);
final AdapterChoiceItem addToPlaylist = new AdapterChoiceItem( final AdapterChoiceItem popupPlayer = new AdapterChoiceItem(
getString(R.string.add_to_playlist_key), getString(R.string.add_to_playlist), getString(R.string.popup_player_key), getString(R.string.popup_player),
R.drawable.ic_add); 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 (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)) { if (capabilities.contains(VIDEO)) {
returnList.add(popupPlayer); returnedItems.add(videoPlayer);
returnedItems.add(popupPlayer);
} }
if (capabilities.contains(AUDIO)) { if (capabilities.contains(AUDIO)) {
returnList.add(backgroundPlayer); returnedItems.add(backgroundPlayer);
} }
// download is redundant for linkType CHANNEL AND PLAYLIST (till playlist downloading is // download is redundant for linkType CHANNEL AND PLAYLIST (till playlist downloading is
// not supported ) // not supported )
returnList.add(new AdapterChoiceItem(getString(R.string.download_key), returnedItems.add(new AdapterChoiceItem(getString(R.string.download_key),
getString(R.string.download), getString(R.string.download),
R.drawable.ic_file_download)); R.drawable.ic_file_download));
// Add to playlist is not necessary for CHANNEL and PLAYLIST linkType since those can // Add to playlist is not necessary for CHANNEL and PLAYLIST linkType since those can
// not be added to a playlist // 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 { } 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) { if (capabilities.contains(VIDEO) && !isExtVideoEnabled) {
returnList.add(videoPlayer); returnedItems.add(videoPlayer);
returnList.add(popupPlayer); returnedItems.add(popupPlayer);
} }
if (capabilities.contains(AUDIO) && !isExtAudioEnabled) { if (capabilities.contains(AUDIO) && !isExtAudioEnabled) {
returnList.add(backgroundPlayer); returnedItems.add(backgroundPlayer);
} }
} }
return returnList; return returnedItems;
} }
private Context getThemeWrapperContext() { private Context getThemeWrapperContext() {
@ -567,7 +586,8 @@ public class RouterActivity extends AppCompatActivity {
// stop and bypass FetcherService if InfoScreen was selected since // stop and bypass FetcherService if InfoScreen was selected since
// StreamDetailFragment can fetch data itself // StreamDetailFragment can fetch data itself
if (selectedChoiceKey.equals(getString(R.string.show_info_key))) { if (selectedChoiceKey.equals(getString(R.string.show_info_key))
|| canHandleChoiceLikeShowInfo(selectedChoiceKey)) {
disposables.add(Observable disposables.add(Observable
.fromCallable(() -> NavigationHelper.getIntentByLink(this, currentUrl)) .fromCallable(() -> NavigationHelper.getIntentByLink(this, currentUrl))
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
@ -590,6 +610,30 @@ public class RouterActivity extends AppCompatActivity {
finish(); finish();
} }
private boolean canHandleChoiceLikeShowInfo(final String selectedChoiceKey) {
if (!selectedChoiceKey.equals(getString(R.string.video_player_key))) {
return false;
}
// "video player" can be handled like "show info" (because VideoDetailFragment can load
// the stream instead of FetcherService) when...
// ...Autoplay is enabled
if (!PlayerHelper.isAutoplayAllowedByUser(getThemeWrapperContext())) {
return false;
}
final boolean isExtVideoEnabled = PreferenceManager.getDefaultSharedPreferences(this)
.getBoolean(getString(R.string.use_external_video_player_key), false);
// ...it's not done via an external player
if (isExtVideoEnabled) {
return false;
}
// ...the player is not running or in normal Video-mode/type
final MainPlayer.PlayerType playerType = PlayerHolder.getInstance().getType();
return playerType == null || playerType == MainPlayer.PlayerType.VIDEO;
}
private void openAddToPlaylistDialog() { private void openAddToPlaylistDialog() {
// Getting the stream info usually takes a moment // Getting the stream info usually takes a moment
// Notifying the user here to ensure that no confusion arises // Notifying the user here to ensure that no confusion arises
@ -631,22 +675,13 @@ public class RouterActivity extends AppCompatActivity {
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe(result -> { .subscribe(result -> {
final List<VideoStream> sortedVideoStreams = ListHelper final DownloadDialog downloadDialog = new DownloadDialog(this, result);
.getSortedStreamVideosList(this, result.getVideoStreams(), downloadDialog.setOnDismissListener(dialog -> finish());
result.getVideoOnlyStreams(), false);
final int selectedVideoStreamIndex = ListHelper
.getDefaultResolutionIndex(this, sortedVideoStreams);
final FragmentManager fm = getSupportFragmentManager(); final FragmentManager fm = getSupportFragmentManager();
final DownloadDialog downloadDialog = DownloadDialog.newInstance(result);
downloadDialog.setVideoStreams(sortedVideoStreams);
downloadDialog.setAudioStreams(result.getAudioStreams());
downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex);
downloadDialog.setOnDismissListener(dialog -> finish());
downloadDialog.show(fm, "downloadDialog"); downloadDialog.show(fm, "downloadDialog");
fm.executePendingTransactions(); fm.executePendingTransactions();
}, throwable -> }, throwable -> showUnsupportedUrlDialog(currentUrl)));
showUnsupportedUrlDialog(currentUrl)));
} }
@Override @Override
@ -672,8 +707,8 @@ public class RouterActivity extends AppCompatActivity {
final int icon; final int icon;
AdapterChoiceItem(final String key, final String description, final int icon) { AdapterChoiceItem(final String key, final String description, final int icon) {
this.description = description;
this.key = key; this.key = key;
this.description = description;
this.icon = icon; this.icon = icon;
} }
} }

View file

@ -10,7 +10,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.R import org.schabi.newpipe.R
@ -21,30 +20,28 @@ import org.schabi.newpipe.util.ThemeHelper
import org.schabi.newpipe.util.external_communication.ShareUtils import org.schabi.newpipe.util.external_communication.ShareUtils
class AboutActivity : AppCompatActivity() { class AboutActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Localization.assureCorrectAppLanguage(this) Localization.assureCorrectAppLanguage(this)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
ThemeHelper.setTheme(this) ThemeHelper.setTheme(this)
title = getString(R.string.title_activity_about) title = getString(R.string.title_activity_about)
val aboutBinding = ActivityAboutBinding.inflate(layoutInflater) val aboutBinding = ActivityAboutBinding.inflate(layoutInflater)
setContentView(aboutBinding.root) setContentView(aboutBinding.root)
setSupportActionBar(aboutBinding.aboutToolbar) setSupportActionBar(aboutBinding.aboutToolbar)
supportActionBar!!.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
// Create the adapter that will return a fragment for each of the three // Create the adapter that will return a fragment for each of the three
// primary sections of the activity. // primary sections of the activity.
val mAboutStateAdapter = AboutStateAdapter(this) val mAboutStateAdapter = AboutStateAdapter(this)
// Set up the ViewPager with the sections adapter. // Set up the ViewPager with the sections adapter.
aboutBinding.aboutViewPager2.adapter = mAboutStateAdapter aboutBinding.aboutViewPager2.adapter = mAboutStateAdapter
TabLayoutMediator( TabLayoutMediator(
aboutBinding.aboutTabLayout, aboutBinding.aboutTabLayout,
aboutBinding.aboutViewPager2 aboutBinding.aboutViewPager2
) { tab: TabLayout.Tab, position: Int -> ) { tab, position ->
when (position) { tab.setText(mAboutStateAdapter.getPageTitle(position))
POS_ABOUT -> tab.setText(R.string.tab_about)
POS_LICENSE -> tab.setText(R.string.tab_licenses)
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
}
}.attach() }.attach()
} }
@ -75,13 +72,14 @@ class AboutActivity : AppCompatActivity() {
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
val aboutBinding = FragmentAboutBinding.inflate(inflater, container, false) FragmentAboutBinding.inflate(inflater, container, false).apply {
aboutBinding.aboutAppVersion.text = BuildConfig.VERSION_NAME aboutAppVersion.text = BuildConfig.VERSION_NAME
aboutBinding.aboutGithubLink.openLink(R.string.github_url) aboutGithubLink.openLink(R.string.github_url)
aboutBinding.aboutDonationLink.openLink(R.string.donation_url) aboutDonationLink.openLink(R.string.donation_url)
aboutBinding.aboutWebsiteLink.openLink(R.string.website_url) aboutWebsiteLink.openLink(R.string.website_url)
aboutBinding.aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url) aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url)
return aboutBinding.root return root
}
} }
} }
@ -90,17 +88,29 @@ class AboutActivity : AppCompatActivity() {
* one of the sections/tabs/pages. * one of the sections/tabs/pages.
*/ */
private class AboutStateAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) { private class AboutStateAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
private val posAbout = 0
private val posLicense = 1
private val totalCount = 2
override fun createFragment(position: Int): Fragment { override fun createFragment(position: Int): Fragment {
return when (position) { return when (position) {
POS_ABOUT -> AboutFragment() posAbout -> AboutFragment()
POS_LICENSE -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS) posLicense -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS)
else -> throw IllegalArgumentException("Unknown position for ViewPager2") else -> throw IllegalArgumentException("Unknown position for ViewPager2")
} }
} }
override fun getItemCount(): Int { override fun getItemCount(): Int {
// Show 2 total pages. // Show 2 total pages.
return TOTAL_COUNT return totalCount
}
fun getPageTitle(position: Int): Int {
return when (position) {
posAbout -> R.string.tab_about
posLicense -> R.string.tab_licenses
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
}
} }
} }
@ -117,10 +127,6 @@ class AboutActivity : AppCompatActivity() {
"AndroidX", "2005 - 2011", "The Android Open Source Project", "AndroidX", "2005 - 2011", "The Android Open Source Project",
"https://developer.android.com/jetpack", StandardLicenses.APACHE2 "https://developer.android.com/jetpack", StandardLicenses.APACHE2
), ),
SoftwareComponent(
"CircleImageView", "2014 - 2020", "Henning Dodenhof",
"https://github.com/hdodenhof/CircleImageView", StandardLicenses.APACHE2
),
SoftwareComponent( SoftwareComponent(
"ExoPlayer", "2014 - 2020", "Google, Inc.", "ExoPlayer", "2014 - 2020", "Google, Inc.",
"https://github.com/google/ExoPlayer", StandardLicenses.APACHE2 "https://github.com/google/ExoPlayer", StandardLicenses.APACHE2
@ -191,8 +197,5 @@ class AboutActivity : AppCompatActivity() {
"https://github.com/ByteHamster/SearchPreference", StandardLicenses.MIT "https://github.com/ByteHamster/SearchPreference", StandardLicenses.MIT
), ),
) )
private const val POS_ABOUT = 0
private const val POS_LICENSE = 1
private const val TOTAL_COUNT = 2
} }
} }

View file

@ -87,60 +87,50 @@ object LicenseFragmentHelper {
return context.getString(color).substring(3) return context.getString(color).substring(3)
} }
@JvmStatic
fun showLicense(context: Context?, license: License): Disposable { fun showLicense(context: Context?, license: License): Disposable {
return showLicense(context, license) { alertDialog ->
alertDialog.setPositiveButton(R.string.ok) { dialog, _ ->
dialog.dismiss()
}
}
}
fun showLicense(context: Context?, component: SoftwareComponent): Disposable {
return showLicense(context, component.license) { alertDialog ->
alertDialog.setPositiveButton(R.string.dismiss) { dialog, _ ->
dialog.dismiss()
}
alertDialog.setNeutralButton(R.string.open_website_license) { _, _ ->
ShareUtils.openUrlInBrowser(context!!, component.link)
}
}
}
private fun showLicense(
context: Context?,
license: License,
block: (AlertDialog.Builder) -> Unit
): Disposable {
return if (context == null) { return if (context == null) {
Disposable.empty() Disposable.empty()
} else { } else {
Observable.fromCallable { getFormattedLicense(context, license) } Observable.fromCallable { getFormattedLicense(context, license) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe { formattedLicense: String -> .subscribe { formattedLicense ->
val webViewData = Base64.encodeToString( val webViewData = Base64.encodeToString(
formattedLicense formattedLicense.toByteArray(StandardCharsets.UTF_8), Base64.NO_PADDING
.toByteArray(StandardCharsets.UTF_8),
Base64.NO_PADDING
) )
val webView = WebView(context) val webView = WebView(context)
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64") webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
val alert = AlertDialog.Builder(context)
alert.setTitle(license.name) AlertDialog.Builder(context).apply {
alert.setView(webView) setTitle(license.name)
Localization.assureCorrectAppLanguage(context) setView(webView)
alert.setNegativeButton( Localization.assureCorrectAppLanguage(context)
context.getString(R.string.ok) block(this)
) { dialog, _ -> dialog.dismiss() } show()
alert.show()
}
}
}
@JvmStatic
fun showLicense(context: Context?, component: SoftwareComponent): Disposable {
return if (context == null) {
Disposable.empty()
} else {
Observable.fromCallable { getFormattedLicense(context, component.license) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { formattedLicense: String ->
val webViewData = Base64.encodeToString(
formattedLicense
.toByteArray(StandardCharsets.UTF_8),
Base64.NO_PADDING
)
val webView = WebView(context)
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
val alert = AlertDialog.Builder(context)
alert.setTitle(component.license.name)
alert.setView(webView)
Localization.assureCorrectAppLanguage(context)
alert.setPositiveButton(
R.string.dismiss
) { dialog, _ -> dialog.dismiss() }
alert.setNeutralButton(R.string.open_website_license) { _, _ ->
ShareUtils.openUrlInBrowser(context, component.link)
} }
alert.show()
} }
} }
} }

View file

@ -1,5 +1,7 @@
package org.schabi.newpipe.database; package org.schabi.newpipe.database;
import static org.schabi.newpipe.database.Migrations.DB_VER_5;
import androidx.room.Database; import androidx.room.Database;
import androidx.room.RoomDatabase; import androidx.room.RoomDatabase;
import androidx.room.TypeConverters; import androidx.room.TypeConverters;
@ -27,8 +29,6 @@ import org.schabi.newpipe.database.stream.model.StreamStateEntity;
import org.schabi.newpipe.database.subscription.SubscriptionDAO; import org.schabi.newpipe.database.subscription.SubscriptionDAO;
import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import static org.schabi.newpipe.database.Migrations.DB_VER_4;
@TypeConverters({Converters.class}) @TypeConverters({Converters.class})
@Database( @Database(
entities = { entities = {
@ -38,7 +38,7 @@ import static org.schabi.newpipe.database.Migrations.DB_VER_4;
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class, FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
FeedLastUpdatedEntity.class FeedLastUpdatedEntity.class
}, },
version = DB_VER_4 version = DB_VER_5
) )
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public static final String DATABASE_NAME = "newpipe.db"; public static final String DATABASE_NAME = "newpipe.db";

View file

@ -22,6 +22,7 @@ public final class Migrations {
public static final int DB_VER_2 = 2; public static final int DB_VER_2 = 2;
public static final int DB_VER_3 = 3; public static final int DB_VER_3 = 3;
public static final int DB_VER_4 = 4; public static final int DB_VER_4 = 4;
public static final int DB_VER_5 = 5;
private static final String TAG = Migrations.class.getName(); private static final String TAG = Migrations.class.getName();
public static final boolean DEBUG = MainActivity.DEBUG; public static final boolean DEBUG = MainActivity.DEBUG;
@ -179,5 +180,14 @@ public final class Migrations {
} }
}; };
private Migrations() { } public static final Migration MIGRATION_4_5 = new Migration(DB_VER_4, DB_VER_5) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` "
+ "INTEGER NOT NULL DEFAULT 0");
}
};
private Migrations() {
}
} }

View file

@ -12,6 +12,7 @@ import org.schabi.newpipe.database.feed.model.FeedEntity
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
import org.schabi.newpipe.database.stream.StreamWithState import org.schabi.newpipe.database.stream.StreamWithState
import org.schabi.newpipe.database.stream.model.StreamStateEntity import org.schabi.newpipe.database.stream.model.StreamStateEntity
import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.database.subscription.SubscriptionEntity
import java.time.OffsetDateTime import java.time.OffsetDateTime
@ -252,4 +253,21 @@ abstract class FeedDAO {
""" """
) )
abstract fun getAllOutdatedForGroup(groupId: Long, outdatedThreshold: OffsetDateTime): Flowable<List<SubscriptionEntity>> abstract fun getAllOutdatedForGroup(groupId: Long, outdatedThreshold: OffsetDateTime): Flowable<List<SubscriptionEntity>>
@Query(
"""
SELECT s.* FROM subscriptions s
LEFT JOIN feed_last_updated lu
ON s.uid = lu.subscription_id
WHERE
(lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold)
AND s.notification_mode = :notificationMode
"""
)
abstract fun getOutdatedWithNotificationMode(
outdatedThreshold: OffsetDateTime,
@NotificationMode notificationMode: Int
): Flowable<List<SubscriptionEntity>>
} }

View file

@ -3,6 +3,7 @@ package org.schabi.newpipe.database.history.dao;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.room.Dao; import androidx.room.Dao;
import androidx.room.Query; import androidx.room.Query;
import androidx.room.RewriteQueriesToDropUnusedColumns;
import org.schabi.newpipe.database.history.model.StreamHistoryEntity; import org.schabi.newpipe.database.history.model.StreamHistoryEntity;
import org.schabi.newpipe.database.history.model.StreamHistoryEntry; import org.schabi.newpipe.database.history.model.StreamHistoryEntry;
@ -67,6 +68,7 @@ public abstract class StreamHistoryDAO implements HistoryDAO<StreamHistoryEntity
@Query("DELETE FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") @Query("DELETE FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
public abstract int deleteStreamHistory(long streamId); public abstract int deleteStreamHistory(long streamId);
@RewriteQueriesToDropUnusedColumns
@Query("SELECT * FROM " + STREAM_TABLE @Query("SELECT * FROM " + STREAM_TABLE
// Select the latest entry and watch count for each stream id on history table // Select the latest entry and watch count for each stream id on history table

View file

@ -4,7 +4,6 @@ import androidx.annotation.NonNull;
import androidx.room.ColumnInfo; import androidx.room.ColumnInfo;
import androidx.room.Entity; import androidx.room.Entity;
import androidx.room.ForeignKey; import androidx.room.ForeignKey;
import androidx.room.Ignore;
import androidx.room.Index; import androidx.room.Index;
import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.database.stream.model.StreamEntity;
@ -42,18 +41,19 @@ public class StreamHistoryEntity {
@ColumnInfo(name = STREAM_REPEAT_COUNT) @ColumnInfo(name = STREAM_REPEAT_COUNT)
private long repeatCount; private long repeatCount;
public StreamHistoryEntity(final long streamUid, @NonNull final OffsetDateTime accessDate, /**
* @param streamUid the stream id this history item will refer to
* @param accessDate the last time the stream was accessed
* @param repeatCount the total number of views this stream received
*/
public StreamHistoryEntity(final long streamUid,
@NonNull final OffsetDateTime accessDate,
final long repeatCount) { final long repeatCount) {
this.streamUid = streamUid; this.streamUid = streamUid;
this.accessDate = accessDate; this.accessDate = accessDate;
this.repeatCount = repeatCount; this.repeatCount = repeatCount;
} }
@Ignore
public StreamHistoryEntity(final long streamUid, @NonNull final OffsetDateTime accessDate) {
this(streamUid, accessDate, 1);
}
public long getStreamUid() { public long getStreamUid() {
return streamUid; return streamUid;
} }

View file

@ -2,6 +2,7 @@ package org.schabi.newpipe.database.playlist.dao;
import androidx.room.Dao; import androidx.room.Dao;
import androidx.room.Query; import androidx.room.Query;
import androidx.room.RewriteQueriesToDropUnusedColumns;
import androidx.room.Transaction; import androidx.room.Transaction;
import org.schabi.newpipe.database.BasicDAO; import org.schabi.newpipe.database.BasicDAO;
@ -52,6 +53,7 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
Flowable<Integer> getMaximumIndexOf(long playlistId); Flowable<Integer> getMaximumIndexOf(long playlistId);
@RewriteQueriesToDropUnusedColumns
@Transaction @Transaction
@Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN " @Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN "
// get ids of streams of the given playlist // get ids of streams of the given playlist

View file

@ -12,8 +12,7 @@ import org.schabi.newpipe.database.BasicDAO
import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM import org.schabi.newpipe.util.StreamTypeUtil
import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM
import java.time.OffsetDateTime import java.time.OffsetDateTime
@Dao @Dao
@ -39,6 +38,9 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
internal abstract fun silentInsertAllInternal(streams: List<StreamEntity>): List<Long> internal abstract fun silentInsertAllInternal(streams: List<StreamEntity>): List<Long>
@Query("SELECT COUNT(*) != 0 FROM streams WHERE url = :url AND service_id = :serviceId")
internal abstract fun exists(serviceId: Int, url: String): Boolean
@Query( @Query(
""" """
SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration
@ -88,8 +90,7 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
?: throw IllegalStateException("Stream cannot be null just after insertion.") ?: throw IllegalStateException("Stream cannot be null just after insertion.")
newerStream.uid = existentMinimalStream.uid newerStream.uid = existentMinimalStream.uid
val isNewerStreamLive = newerStream.streamType == AUDIO_LIVE_STREAM || newerStream.streamType == LIVE_STREAM if (!StreamTypeUtil.isLiveStream(newerStream.streamType)) {
if (!isNewerStreamLive) {
// Use the existent upload date if the newer stream does not have a better precision // Use the existent upload date if the newer stream does not have a better precision
// (i.e. is an approximation). This is done to prevent unnecessary changes. // (i.e. is an approximation). This is done to prevent unnecessary changes.

View file

@ -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
}

View file

@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import androidx.room.Transaction import androidx.room.Transaction
import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe import io.reactivex.rxjava3.core.Maybe
@ -31,6 +32,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
) )
abstract fun getSubscriptionsFiltered(filter: String): Flowable<List<SubscriptionEntity>> abstract fun getSubscriptionsFiltered(filter: String): Flowable<List<SubscriptionEntity>>
@RewriteQueriesToDropUnusedColumns
@Query( @Query(
""" """
SELECT * FROM subscriptions s SELECT * FROM subscriptions s
@ -47,6 +49,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
currentGroupId: Long currentGroupId: Long
): Flowable<List<SubscriptionEntity>> ): Flowable<List<SubscriptionEntity>>
@RewriteQueriesToDropUnusedColumns
@Query( @Query(
""" """
SELECT * FROM subscriptions s SELECT * FROM subscriptions s

View file

@ -26,6 +26,7 @@ public class SubscriptionEntity {
public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url"; public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url";
public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count"; public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
public static final String SUBSCRIPTION_DESCRIPTION = "description"; public static final String SUBSCRIPTION_DESCRIPTION = "description";
public static final String SUBSCRIPTION_NOTIFICATION_MODE = "notification_mode";
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
private long uid = 0; private long uid = 0;
@ -48,6 +49,9 @@ public class SubscriptionEntity {
@ColumnInfo(name = SUBSCRIPTION_DESCRIPTION) @ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
private String description; private String description;
@ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE)
private int notificationMode;
@Ignore @Ignore
public static SubscriptionEntity from(@NonNull final ChannelInfo info) { public static SubscriptionEntity from(@NonNull final ChannelInfo info) {
final SubscriptionEntity result = new SubscriptionEntity(); final SubscriptionEntity result = new SubscriptionEntity();
@ -114,6 +118,15 @@ public class SubscriptionEntity {
this.description = description; this.description = description;
} }
@NotificationMode
public int getNotificationMode() {
return notificationMode;
}
public void setNotificationMode(@NotificationMode final int notificationMode) {
this.notificationMode = notificationMode;
}
@Ignore @Ignore
public void setData(final String n, final String au, final String d, final Long sc) { public void setData(final String n, final String au, final String d, final Long sc) {
this.setName(n); this.setName(n);

View file

@ -61,6 +61,7 @@ import org.schabi.newpipe.util.FilenameUtils;
import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.ListHelper;
import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.PermissionHelper;
import org.schabi.newpipe.util.SecondaryStreamHelper; import org.schabi.newpipe.util.SecondaryStreamHelper;
import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
import org.schabi.newpipe.util.StreamItemAdapter; import org.schabi.newpipe.util.StreamItemAdapter;
import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper; import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper;
import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.ThemeHelper;
@ -68,9 +69,9 @@ import org.schabi.newpipe.util.VideoSegment;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects;
import icepick.Icepick; import icepick.Icepick;
import icepick.State; import icepick.State;
@ -82,6 +83,8 @@ import us.shandian.giga.service.DownloadManagerService;
import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder; import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder;
import us.shandian.giga.service.MissionState; import us.shandian.giga.service.MissionState;
import static org.schabi.newpipe.extractor.stream.DeliveryMethod.PROGRESSIVE_HTTP;
import static org.schabi.newpipe.util.ListHelper.getStreamsOfSpecifiedDelivery;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
public class DownloadDialog extends DialogFragment public class DownloadDialog extends DialogFragment
@ -92,17 +95,17 @@ public class DownloadDialog extends DialogFragment
@State @State
StreamInfo currentInfo; StreamInfo currentInfo;
@State @State
StreamSizeWrapper<AudioStream> wrappedAudioStreams = StreamSizeWrapper.empty(); StreamSizeWrapper<AudioStream> wrappedAudioStreams;
@State @State
StreamSizeWrapper<VideoStream> wrappedVideoStreams = StreamSizeWrapper.empty(); StreamSizeWrapper<VideoStream> wrappedVideoStreams;
@State @State
StreamSizeWrapper<SubtitlesStream> wrappedSubtitleStreams = StreamSizeWrapper.empty(); StreamSizeWrapper<SubtitlesStream> wrappedSubtitleStreams;
@State @State
int selectedVideoIndex = 0; int selectedVideoIndex; // set in the constructor
@State @State
int selectedAudioIndex = 0; int selectedAudioIndex = 0; // default to the first item
@State @State
int selectedSubtitleIndex = 0; int selectedSubtitleIndex = 0; // default to the first item
@Nullable @Nullable
private OnDismissListener onDismissListener = null; private OnDismissListener onDismissListener = null;
@ -145,81 +148,47 @@ public class DownloadDialog extends DialogFragment
// Instance creation // Instance creation
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
public static DownloadDialog newInstance(final StreamInfo info) { /**
final DownloadDialog dialog = new DownloadDialog(); * Create a new download dialog with the video, audio and subtitle streams from the provided
dialog.setInfo(info); * stream info. Video streams and video-only streams will be put into a single list menu,
return dialog; * sorted according to their resolution and the default video resolution will be selected.
} *
* @param context the context to use just to obtain preferences and strings (will not be stored)
public static DownloadDialog newInstance(final Context context, final StreamInfo info) { * @param info the info from which to obtain downloadable streams and other info (e.g. title)
final ArrayList<VideoStream> streamsList = new ArrayList<>(ListHelper */
.getSortedStreamVideosList(context, info.getVideoStreams(), public DownloadDialog(final Context context, @NonNull final StreamInfo info) {
info.getVideoOnlyStreams(), false));
final int selectedStreamIndex = ListHelper.getDefaultResolutionIndex(context, streamsList);
final DownloadDialog instance = newInstance(info);
instance.setVideoStreams(streamsList);
instance.setSelectedVideoStream(selectedStreamIndex);
instance.setAudioStreams(info.getAudioStreams());
instance.setSubtitleStreams(info.getSubtitles());
return instance;
}
/*//////////////////////////////////////////////////////////////////////////
// Setters
//////////////////////////////////////////////////////////////////////////*/
private void setInfo(final StreamInfo info) {
this.currentInfo = info; this.currentInfo = info;
}
public void setAudioStreams(final List<AudioStream> audioStreams) { // TODO: Adapt this code when the downloader support other types of stream deliveries
setAudioStreams(new StreamSizeWrapper<>(audioStreams, getContext())); final List<VideoStream> videoStreams = ListHelper.getSortedStreamVideosList(
} context,
getStreamsOfSpecifiedDelivery(info.getVideoStreams(), PROGRESSIVE_HTTP),
getStreamsOfSpecifiedDelivery(info.getVideoOnlyStreams(), PROGRESSIVE_HTTP),
false,
false
);
public void setAudioStreams(final StreamSizeWrapper<AudioStream> was) { this.wrappedVideoStreams = new StreamSizeWrapper<>(videoStreams, context);
this.wrappedAudioStreams = was; this.wrappedAudioStreams = new StreamSizeWrapper<>(
} getStreamsOfSpecifiedDelivery(info.getAudioStreams(), PROGRESSIVE_HTTP), context);
this.wrappedSubtitleStreams = new StreamSizeWrapper<>(
getStreamsOfSpecifiedDelivery(info.getSubtitles(), PROGRESSIVE_HTTP), context);
public void setVideoStreams(final List<VideoStream> videoStreams) { this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams);
setVideoStreams(new StreamSizeWrapper<>(videoStreams, getContext()));
}
public void setVideoStreams(final StreamSizeWrapper<VideoStream> wvs) {
this.wrappedVideoStreams = wvs;
}
public void setSubtitleStreams(final List<SubtitlesStream> subtitleStreams) {
setSubtitleStreams(new StreamSizeWrapper<>(subtitleStreams, getContext()));
}
public void setSubtitleStreams(
final StreamSizeWrapper<SubtitlesStream> wss) {
this.wrappedSubtitleStreams = wss;
}
public void setSelectedVideoStream(final int svi) {
this.selectedVideoIndex = svi;
}
public void setSelectedAudioStream(final int sai) {
this.selectedAudioIndex = sai;
}
public void setSelectedSubtitleStream(final int ssi) {
this.selectedSubtitleIndex = ssi;
} }
public void setVideoSegments(final VideoSegment[] seg) { public void setVideoSegments(final VideoSegment[] seg) {
this.segments = seg; this.segments = seg;
} }
/**
* @param onDismissListener the listener to call in {@link #onDismiss(DialogInterface)}
*/
public void setOnDismissListener(@Nullable final OnDismissListener onDismissListener) { public void setOnDismissListener(@Nullable final OnDismissListener onDismissListener) {
this.onDismissListener = onDismissListener; this.onDismissListener = onDismissListener;
} }
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Android lifecycle // Android lifecycle
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@ -255,11 +224,16 @@ public class DownloadDialog extends DialogFragment
.getAudioStreamFor(wrappedAudioStreams.getStreamsList(), videoStreams.get(i)); .getAudioStreamFor(wrappedAudioStreams.getStreamsList(), videoStreams.get(i));
if (audioStream != null) { if (audioStream != null) {
secondaryStreams secondaryStreams.append(i, new SecondaryStreamHelper<>(wrappedAudioStreams,
.append(i, new SecondaryStreamHelper<>(wrappedAudioStreams, audioStream)); audioStream));
} else if (DEBUG) { } else if (DEBUG) {
Log.w(TAG, "No audio stream candidates for video format " final MediaFormat mediaFormat = videoStreams.get(i).getFormat();
+ videoStreams.get(i).getFormat().name()); 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 @Override
public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, public View onCreateView(@NonNull final LayoutInflater inflater,
final ViewGroup container,
final Bundle savedInstanceState) { final Bundle savedInstanceState) {
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "onCreateView() called with: " Log.d(TAG, "onCreateView() called with: "
@ -305,14 +280,15 @@ public class DownloadDialog extends DialogFragment
} }
@Override @Override
public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { public void onViewCreated(@NonNull final View view,
@Nullable final Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState); super.onViewCreated(view, savedInstanceState);
dialogBinding = DownloadDialogBinding.bind(view); dialogBinding = DownloadDialogBinding.bind(view);
dialogBinding.fileName.setText(FilenameUtils.createFilename(getContext(), dialogBinding.fileName.setText(FilenameUtils.createFilename(getContext(),
currentInfo.getName())); currentInfo.getName()));
selectedAudioIndex = ListHelper selectedAudioIndex = ListHelper
.getDefaultAudioFormat(getContext(), currentInfo.getAudioStreams()); .getDefaultAudioFormat(getContext(), wrappedAudioStreams.getStreamsList());
selectedSubtitleIndex = getSubtitleIndexBy(subtitleStreamsAdapter.getAll()); selectedSubtitleIndex = getSubtitleIndexBy(subtitleStreamsAdapter.getAll());
@ -328,21 +304,16 @@ public class DownloadDialog extends DialogFragment
final int threads = prefs.getInt(getString(R.string.default_download_threads), 3); final int threads = prefs.getInt(getString(R.string.default_download_threads), 3);
dialogBinding.threadsCount.setText(String.valueOf(threads)); dialogBinding.threadsCount.setText(String.valueOf(threads));
dialogBinding.threads.setProgress(threads - 1); dialogBinding.threads.setProgress(threads - 1);
dialogBinding.threads.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { dialogBinding.threads.setOnSeekBarChangeListener(new SimpleOnSeekBarChangeListener() {
@Override @Override
public void onProgressChanged(final SeekBar seekbar, final int progress, public void onProgressChanged(@NonNull final SeekBar seekbar,
final int progress,
final boolean fromUser) { final boolean fromUser) {
final int newProgress = progress + 1; final int newProgress = progress + 1;
prefs.edit().putInt(getString(R.string.default_download_threads), newProgress) prefs.edit().putInt(getString(R.string.default_download_threads), newProgress)
.apply(); .apply();
dialogBinding.threadsCount.setText(String.valueOf(newProgress)); dialogBinding.threadsCount.setText(String.valueOf(newProgress));
} }
@Override
public void onStartTrackingTouch(final SeekBar p1) { }
@Override
public void onStopTrackingTouch(final SeekBar p1) { }
}); });
fetchStreamsSize(); fetchStreamsSize();
@ -481,7 +452,7 @@ public class DownloadDialog extends DialogFragment
result, getString(R.string.download_path_video_key), DownloadManager.TAG_VIDEO); result, getString(R.string.download_path_video_key), DownloadManager.TAG_VIDEO);
} }
private void requestDownloadSaveAsResult(final ActivityResult result) { private void requestDownloadSaveAsResult(@NonNull final ActivityResult result) {
if (result.getResultCode() != Activity.RESULT_OK) { if (result.getResultCode() != Activity.RESULT_OK) {
return; return;
} }
@ -498,8 +469,8 @@ public class DownloadDialog extends DialogFragment
return; return;
} }
final DocumentFile docFile final DocumentFile docFile = DocumentFile.fromSingleUri(context,
= DocumentFile.fromSingleUri(context, result.getData().getData()); result.getData().getData());
if (docFile == null) { if (docFile == null) {
showFailedDialog(R.string.general_error); showFailedDialog(R.string.general_error);
return; return;
@ -510,7 +481,7 @@ public class DownloadDialog extends DialogFragment
docFile.getType()); docFile.getType());
} }
private void requestDownloadPickFolderResult(final ActivityResult result, private void requestDownloadPickFolderResult(@NonNull final ActivityResult result,
final String key, final String key,
final String tag) { final String tag) {
if (result.getResultCode() != Activity.RESULT_OK) { if (result.getResultCode() != Activity.RESULT_OK) {
@ -530,12 +501,11 @@ public class DownloadDialog extends DialogFragment
StoredDirectoryHelper.PERMISSION_FLAGS); StoredDirectoryHelper.PERMISSION_FLAGS);
} }
PreferenceManager.getDefaultSharedPreferences(context).edit() PreferenceManager.getDefaultSharedPreferences(context).edit().putString(key,
.putString(key, uri.toString()).apply(); uri.toString()).apply();
try { try {
final StoredDirectoryHelper mainStorage final StoredDirectoryHelper mainStorage = new StoredDirectoryHelper(context, uri, tag);
= new StoredDirectoryHelper(context, uri, tag);
checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp),
filenameTmp, mimeTmp); filenameTmp, mimeTmp);
} catch (final IOException e) { } catch (final IOException e) {
@ -573,8 +543,10 @@ public class DownloadDialog extends DialogFragment
} }
@Override @Override
public void onItemSelected(final AdapterView<?> parent, final View view, public void onItemSelected(final AdapterView<?> parent,
final int position, final long id) { final View view,
final int position,
final long id) {
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "onItemSelected() called with: " Log.d(TAG, "onItemSelected() called with: "
+ "parent = [" + parent + "], view = [" + view + "], " + "parent = [" + parent + "], view = [" + view + "], "
@ -609,14 +581,16 @@ public class DownloadDialog extends DialogFragment
final boolean isAudioStreamsAvailable = audioStreamsAdapter.getCount() > 0; final boolean isAudioStreamsAvailable = audioStreamsAdapter.getCount() > 0;
final boolean isSubtitleStreamsAvailable = subtitleStreamsAdapter.getCount() > 0; final boolean isSubtitleStreamsAvailable = subtitleStreamsAdapter.getCount() > 0;
dialogBinding.audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE : View.GONE); dialogBinding.audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE
dialogBinding.videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE : View.GONE); : View.GONE);
dialogBinding.videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE
: View.GONE);
dialogBinding.subtitleButton.setVisibility(isSubtitleStreamsAvailable dialogBinding.subtitleButton.setVisibility(isSubtitleStreamsAvailable
? View.VISIBLE : View.GONE); ? View.VISIBLE : View.GONE);
prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); prefs = PreferenceManager.getDefaultSharedPreferences(requireContext());
final String defaultMedia = prefs.getString(getString(R.string.last_used_download_type), 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 if (isVideoStreamsAvailable
&& (defaultMedia.equals(getString(R.string.last_download_type_video_key)))) { && (defaultMedia.equals(getString(R.string.last_download_type_video_key)))) {
@ -652,7 +626,7 @@ public class DownloadDialog extends DialogFragment
dialogBinding.subtitleButton.setEnabled(enabled); dialogBinding.subtitleButton.setEnabled(enabled);
} }
private int getSubtitleIndexBy(final List<SubtitlesStream> streams) { private int getSubtitleIndexBy(@NonNull final List<SubtitlesStream> streams) {
final Localization preferredLocalization = NewPipe.getPreferredLocalization(); final Localization preferredLocalization = NewPipe.getPreferredLocalization();
int candidate = 0; int candidate = 0;
@ -678,8 +652,10 @@ public class DownloadDialog extends DialogFragment
return candidate; return candidate;
} }
@NonNull
private String getNameEditText() { private String getNameEditText() {
final String str = dialogBinding.fileName.getText().toString().trim(); final String str = Objects.requireNonNull(dialogBinding.fileName.getText()).toString()
.trim();
return FilenameUtils.createFilename(context, str.isEmpty() ? currentInfo.getName() : str); return FilenameUtils.createFilename(context, str.isEmpty() ? currentInfo.getName() : str);
} }
@ -695,12 +671,8 @@ public class DownloadDialog extends DialogFragment
} }
private void launchDirectoryPicker(final ActivityResultLauncher<Intent> launcher) { private void launchDirectoryPicker(final ActivityResultLauncher<Intent> launcher) {
NoFileManagerSafeGuard.launchSafe( NoFileManagerSafeGuard.launchSafe(launcher, StoredDirectoryHelper.getPicker(context), TAG,
launcher, context);
StoredDirectoryHelper.getPicker(context),
TAG,
context
);
} }
private void prepareSelectedDownload() { private void prepareSelectedDownload() {
@ -721,7 +693,7 @@ public class DownloadDialog extends DialogFragment
if (format == MediaFormat.WEBMA_OPUS) { if (format == MediaFormat.WEBMA_OPUS) {
mimeTmp = "audio/ogg"; mimeTmp = "audio/ogg";
filenameTmp += "opus"; filenameTmp += "opus";
} else { } else if (format != null) {
mimeTmp = format.mimeType; mimeTmp = format.mimeType;
filenameTmp += format.suffix; filenameTmp += format.suffix;
} }
@ -730,22 +702,30 @@ public class DownloadDialog extends DialogFragment
selectedMediaType = getString(R.string.last_download_type_video_key); selectedMediaType = getString(R.string.last_download_type_video_key);
mainStorage = mainStorageVideo; mainStorage = mainStorageVideo;
format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat(); format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat();
mimeTmp = format.mimeType; if (format != null) {
filenameTmp += format.suffix; mimeTmp = format.mimeType;
filenameTmp += format.suffix;
}
break; break;
case R.id.subtitle_button: case R.id.subtitle_button:
selectedMediaType = getString(R.string.last_download_type_subtitle_key); selectedMediaType = getString(R.string.last_download_type_subtitle_key);
mainStorage = mainStorageVideo; // subtitle & video files go together mainStorage = mainStorageVideo; // subtitle & video files go together
format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat(); format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat();
mimeTmp = format.mimeType; if (format != null) {
filenameTmp += (format == MediaFormat.TTML ? MediaFormat.SRT : format).suffix; mimeTmp = format.mimeType;
}
if (format == MediaFormat.TTML) {
filenameTmp += MediaFormat.SRT.suffix;
} else if (format != null) {
filenameTmp += format.suffix;
}
break; break;
default: default:
throw new RuntimeException("No stream selected"); throw new RuntimeException("No stream selected");
} }
if (!askForSavePath if (!askForSavePath && (mainStorage == null
&& (mainStorage == null
|| mainStorage.isDirect() == NewPipeSettings.useStorageAccessFramework(context) || mainStorage.isDirect() == NewPipeSettings.useStorageAccessFramework(context)
|| mainStorage.isInvalidSafStorage())) { || mainStorage.isInvalidSafStorage())) {
// Pick new download folder if one of: // Pick new download folder if one of:
@ -779,18 +759,16 @@ public class DownloadDialog extends DialogFragment
initialPath = Uri.parse(initialSavePath.getAbsolutePath()); initialPath = Uri.parse(initialSavePath.getAbsolutePath());
} }
NoFileManagerSafeGuard.launchSafe( NoFileManagerSafeGuard.launchSafe(requestDownloadSaveAsLauncher,
requestDownloadSaveAsLauncher, StoredFileHelper.getNewPicker(context, filenameTmp, mimeTmp, initialPath), TAG,
StoredFileHelper.getNewPicker(context, filenameTmp, mimeTmp, initialPath), context);
TAG,
context
);
return; return;
} }
// check for existing file with the same name // check for existing file with the same name
checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp, mimeTmp); checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp,
mimeTmp);
// remember the last media type downloaded by the user // remember the last media type downloaded by the user
prefs.edit().putString(getString(R.string.last_used_download_type), selectedMediaType) prefs.edit().putString(getString(R.string.last_used_download_type), selectedMediaType)
@ -798,7 +776,8 @@ public class DownloadDialog extends DialogFragment
} }
private void checkSelectedDownload(final StoredDirectoryHelper mainStorage, private void checkSelectedDownload(final StoredDirectoryHelper mainStorage,
final Uri targetFile, final String filename, final Uri targetFile,
final String filename,
final String mime) { final String mime) {
StoredFileHelper storage; StoredFileHelper storage;
@ -959,7 +938,7 @@ public class DownloadDialog extends DialogFragment
storage.truncate(); storage.truncate();
} }
} catch (final IOException e) { } catch (final IOException e) {
Log.e(TAG, "failed to truncate the file: " + storage.getUri().toString(), e); Log.e(TAG, "Failed to truncate the file: " + storage.getUri().toString(), e);
showFailedDialog(R.string.overwrite_failed); showFailedDialog(R.string.overwrite_failed);
return; return;
} }
@ -1004,8 +983,8 @@ public class DownloadDialog extends DialogFragment
} }
psArgs = null; psArgs = null;
final long videoSize = wrappedVideoStreams final long videoSize = wrappedVideoStreams.getSizeInBytes(
.getSizeInBytes((VideoStream) selectedStream); (VideoStream) selectedStream);
// set nearLength, only, if both sizes are fetched or known. This probably // set nearLength, only, if both sizes are fetched or known. This probably
// does not work on slow networks but is later updated in the downloader // does not work on slow networks but is later updated in the downloader
@ -1021,7 +1000,7 @@ public class DownloadDialog extends DialogFragment
if (selectedStream.getFormat() == MediaFormat.TTML) { if (selectedStream.getFormat() == MediaFormat.TTML) {
psName = Postprocessing.ALGORITHM_TTML_CONVERTER; psName = Postprocessing.ALGORITHM_TTML_CONVERTER;
psArgs = new String[]{ psArgs = new String[] {
selectedStream.getFormat().getSuffix(), selectedStream.getFormat().getSuffix(),
"false" // ignore empty frames "false" // ignore empty frames
}; };
@ -1032,17 +1011,22 @@ public class DownloadDialog extends DialogFragment
} }
if (secondaryStream == null) { if (secondaryStream == null) {
urls = new String[]{ urls = new String[] {
selectedStream.getUrl() selectedStream.getContent()
}; };
recoveryInfo = new MissionRecoveryInfo[]{ recoveryInfo = new MissionRecoveryInfo[] {
new MissionRecoveryInfo(selectedStream) new MissionRecoveryInfo(selectedStream)
}; };
} else { } else {
urls = new String[]{ if (secondaryStream.getDeliveryMethod() != PROGRESSIVE_HTTP) {
selectedStream.getUrl(), secondaryStream.getUrl() 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)}; new MissionRecoveryInfo(secondaryStream)};
} }

View file

@ -7,13 +7,13 @@ import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.Info import org.schabi.newpipe.extractor.Info
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
import org.schabi.newpipe.extractor.exceptions.ExtractionException import org.schabi.newpipe.extractor.exceptions.ExtractionException
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor.DeobfuscateException import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor.DeobfuscateException
import org.schabi.newpipe.ktx.isNetworkRelated import org.schabi.newpipe.ktx.isNetworkRelated
import org.schabi.newpipe.util.ServiceHelper
import java.io.PrintWriter import java.io.PrintWriter
import java.io.StringWriter import java.io.StringWriter
@ -65,7 +65,7 @@ class ErrorInfo(
constructor(throwable: Throwable, userAction: UserAction, request: String) : constructor(throwable: Throwable, userAction: UserAction, request: String) :
this(throwable, userAction, SERVICE_NONE, request) this(throwable, userAction, SERVICE_NONE, request)
constructor(throwable: Throwable, userAction: UserAction, request: String, serviceId: Int) : constructor(throwable: Throwable, userAction: UserAction, request: String, serviceId: Int) :
this(throwable, userAction, NewPipe.getNameOfService(serviceId), request) this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request)
constructor(throwable: Throwable, userAction: UserAction, request: String, info: Info?) : constructor(throwable: Throwable, userAction: UserAction, request: String, info: Info?) :
this(throwable, userAction, getInfoServiceName(info), request) this(throwable, userAction, getInfoServiceName(info), request)
@ -73,7 +73,7 @@ class ErrorInfo(
constructor(throwable: List<Throwable>, userAction: UserAction, request: String) : constructor(throwable: List<Throwable>, userAction: UserAction, request: String) :
this(throwable, userAction, SERVICE_NONE, request) this(throwable, userAction, SERVICE_NONE, request)
constructor(throwable: List<Throwable>, userAction: UserAction, request: String, serviceId: Int) : constructor(throwable: List<Throwable>, userAction: UserAction, request: String, serviceId: Int) :
this(throwable, userAction, NewPipe.getNameOfService(serviceId), request) this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request)
constructor(throwable: List<Throwable>, userAction: UserAction, request: String, info: Info?) : constructor(throwable: List<Throwable>, userAction: UserAction, request: String, info: Info?) :
this(throwable, userAction, getInfoServiceName(info), request) this(throwable, userAction, getInfoServiceName(info), request)
@ -95,7 +95,7 @@ class ErrorInfo(
Array(throwable.size) { i -> getStackTrace(throwable[i]) } Array(throwable.size) { i -> getStackTrace(throwable[i]) }
private fun getInfoServiceName(info: Info?) = private fun getInfoServiceName(info: Info?) =
if (info == null) SERVICE_NONE else NewPipe.getNameOfService(info.serviceId) if (info == null) SERVICE_NONE else ServiceHelper.getNameOfServiceById(info.serviceId)
@StringRes @StringRes
private fun getMessageStringId( private fun getMessageStringId(

View file

@ -15,7 +15,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import org.schabi.newpipe.MainActivity import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
@ -106,7 +105,7 @@ class ErrorPanelHelper(
if (!isNullOrEmpty((errorInfo.throwable as AccountTerminatedException).message)) { if (!isNullOrEmpty((errorInfo.throwable as AccountTerminatedException).message)) {
errorServiceInfoTextView.text = context.resources.getString( errorServiceInfoTextView.text = context.resources.getString(
R.string.service_provides_reason, R.string.service_provides_reason,
NewPipe.getNameOfService(ServiceHelper.getSelectedServiceId(context)) ServiceHelper.getSelectedService(context)?.serviceInfo?.name ?: "<unknown>"
) )
errorServiceInfoTextView.isVisible = true errorServiceInfoTextView.isVisible = true

View file

@ -1,7 +1,6 @@
package org.schabi.newpipe.error package org.schabi.newpipe.error
import android.app.Activity import android.app.Activity
import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -10,7 +9,7 @@ import android.os.Build
import android.view.View import android.view.View
import android.widget.Toast import android.widget.Toast
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.app.NotificationManagerCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
@ -114,13 +113,6 @@ class ErrorUtil {
return return
} }
val notificationManager =
ContextCompat.getSystemService(context, NotificationManager::class.java)
if (notificationManager == null) {
// this should never happen, but just in case open error activity
openActivity(context, errorInfo)
}
var pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT var pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
pendingIntentFlags = pendingIntentFlags or PendingIntent.FLAG_IMMUTABLE pendingIntentFlags = pendingIntentFlags or PendingIntent.FLAG_IMMUTABLE
@ -131,7 +123,13 @@ class ErrorUtil {
context, context,
context.getString(R.string.error_report_channel_id) context.getString(R.string.error_report_channel_id)
) )
.setSmallIcon(R.drawable.ic_bug_report) .setSmallIcon(
// the vector drawable icon causes crashes on KitKat devices
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
R.drawable.ic_bug_report
else
android.R.drawable.stat_notify_error
)
.setContentTitle(context.getString(R.string.error_report_notification_title)) .setContentTitle(context.getString(R.string.error_report_notification_title))
.setContentText(context.getString(errorInfo.messageStringId)) .setContentText(context.getString(errorInfo.messageStringId))
.setAutoCancel(true) .setAutoCancel(true)
@ -144,7 +142,8 @@ class ErrorUtil {
) )
) )
notificationManager!!.notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build()) NotificationManagerCompat.from(context)
.notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build())
// since the notification is silent, also show a toast, otherwise the user is confused // since the notification is silent, also show a toast, otherwise the user is confused
Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT) Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT)

View file

@ -26,10 +26,11 @@ public enum UserAction {
DOWNLOAD_OPEN_DIALOG("download open dialog"), DOWNLOAD_OPEN_DIALOG("download open dialog"),
DOWNLOAD_POSTPROCESSING("download post-processing"), DOWNLOAD_POSTPROCESSING("download post-processing"),
DOWNLOAD_FAILED("download failed"), DOWNLOAD_FAILED("download failed"),
NEW_STREAMS_NOTIFICATIONS("new streams notifications"),
PREFERENCES_MIGRATION("migration of preferences"), PREFERENCES_MIGRATION("migration of preferences"),
SHARE_TO_NEWPIPE("share to newpipe"), SHARE_TO_NEWPIPE("share to newpipe"),
CHECK_FOR_NEW_APP_VERSION("check for new app version"); CHECK_FOR_NEW_APP_VERSION("check for new app version"),
OPEN_INFO_ITEM_DIALOG("open info item dialog");
private final String message; private final String message;

View file

@ -1,5 +1,6 @@
package org.schabi.newpipe.fragments; package org.schabi.newpipe.fragments;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.StaggeredGridLayoutManager; import androidx.recyclerview.widget.StaggeredGridLayoutManager;
@ -10,7 +11,7 @@ import androidx.recyclerview.widget.StaggeredGridLayoutManager;
*/ */
public abstract class OnScrollBelowItemsListener extends RecyclerView.OnScrollListener { public abstract class OnScrollBelowItemsListener extends RecyclerView.OnScrollListener {
@Override @Override
public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) { public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) {
super.onScrolled(recyclerView, dx, dy); super.onScrolled(recyclerView, dx, dy);
if (dy > 0) { if (dy > 0) {
int pastVisibleItems = 0; int pastVisibleItems = 0;

View file

@ -84,7 +84,7 @@ public class DescriptionFragment extends BaseFragment {
private void setupDescription() { private void setupDescription() {
final Description description = streamInfo.getDescription(); final Description description = streamInfo.getDescription();
if (description == null || isEmpty(description.getContent()) if (description == null || isEmpty(description.getContent())
|| description == Description.emptyDescription) { || description == Description.EMPTY_DESCRIPTION) {
binding.detailDescriptionView.setVisibility(View.GONE); binding.detailDescriptionView.setVisibility(View.GONE);
binding.detailSelectDescriptionButton.setVisibility(View.GONE); binding.detailSelectDescriptionButton.setVisibility(View.GONE);
return; return;

View file

@ -1,5 +1,7 @@
package org.schabi.newpipe.fragments.detail; package org.schabi.newpipe.fragments.detail;
import androidx.annotation.NonNull;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import java.io.Serializable; import java.io.Serializable;
@ -46,6 +48,7 @@ class StackItem implements Serializable {
return playQueue; return playQueue;
} }
@NonNull
@Override @Override
public String toString() { public String toString() {
return getServiceId() + ":" + getUrl() + " > " + getTitle(); return getServiceId() + ":" + getUrl() + " > " + getTitle();

View file

@ -31,6 +31,7 @@ import android.view.WindowManager;
import android.view.animation.DecelerateInterpolator; import android.view.animation.DecelerateInterpolator;
import android.widget.FrameLayout; import android.widget.FrameLayout;
import android.widget.RelativeLayout; import android.widget.RelativeLayout;
import android.widget.Toast;
import androidx.annotation.AttrRes; import androidx.annotation.AttrRes;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
@ -43,7 +44,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.PlaybackException;
import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.material.appbar.AppBarLayout; import com.google.android.material.appbar.AppBarLayout;
import com.google.android.material.bottomsheet.BottomSheetBehavior; import com.google.android.material.bottomsheet.BottomSheetBehavior;
@ -97,6 +98,7 @@ import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.PermissionHelper;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.StreamTypeUtil;
import org.schabi.newpipe.util.SponsorBlockUtils; import org.schabi.newpipe.util.SponsorBlockUtils;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.ThemeHelper;
@ -125,6 +127,7 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientat
import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfirmationRequired; import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfirmationRequired;
import static org.schabi.newpipe.player.playqueue.PlayQueueItem.RECOVERY_UNSET; import static org.schabi.newpipe.player.playqueue.PlayQueueItem.RECOVERY_UNSET;
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams;
public final class VideoDetailFragment public final class VideoDetailFragment
extends BaseStateFragment<StreamInfo> extends BaseStateFragment<StreamInfo>
@ -192,8 +195,6 @@ public final class VideoDetailFragment
@Nullable @Nullable
private Disposable videoSegmentsSubscriber = null; private Disposable videoSegmentsSubscriber = null;
private List<VideoStream> sortedVideoStreams;
private int selectedVideoStreamIndex = -1;
private BottomSheetBehavior<FrameLayout> bottomSheetBehavior; private BottomSheetBehavior<FrameLayout> bottomSheetBehavior;
private BroadcastReceiver broadcastReceiver; private BroadcastReceiver broadcastReceiver;
@ -672,8 +673,7 @@ public final class VideoDetailFragment
binding.detailControlsCrashThePlayer.setOnClickListener( binding.detailControlsCrashThePlayer.setOnClickListener(
v -> VideoDetailPlayerCrasher.onCrashThePlayer( v -> VideoDetailPlayerCrasher.onCrashThePlayer(
this.getContext(), this.getContext(),
this.player, this.player)
getLayoutInflater())
); );
} }
@ -1102,9 +1102,6 @@ public final class VideoDetailFragment
} }
private void openBackgroundPlayer(final boolean append) { private void openBackgroundPlayer(final boolean append) {
final AudioStream audioStream = currentInfo.getAudioStreams()
.get(ListHelper.getDefaultAudioFormat(activity, currentInfo.getAudioStreams()));
final boolean useExternalAudioPlayer = PreferenceManager final boolean useExternalAudioPlayer = PreferenceManager
.getDefaultSharedPreferences(activity) .getDefaultSharedPreferences(activity)
.getBoolean(activity.getString(R.string.use_external_audio_player_key), false); .getBoolean(activity.getString(R.string.use_external_audio_player_key), false);
@ -1119,7 +1116,17 @@ public final class VideoDetailFragment
if (!useExternalAudioPlayer) { if (!useExternalAudioPlayer) {
openNormalBackgroundPlayer(append); openNormalBackgroundPlayer(append);
} else { } else {
startOnExternalPlayer(activity, currentInfo, audioStream); final List<AudioStream> audioStreams = getUrlAndNonTorrentStreams(
currentInfo.getAudioStreams());
final int index = ListHelper.getDefaultAudioFormat(activity, audioStreams);
if (index == -1) {
Toast.makeText(activity, R.string.no_audio_streams_available_for_external_players,
Toast.LENGTH_SHORT).show();
return;
}
startOnExternalPlayer(activity, currentInfo, audioStreams.get(index));
} }
} }
@ -1635,13 +1642,6 @@ public final class VideoDetailFragment
binding.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE); binding.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE);
binding.detailSecondaryControlPanel.setVisibility(View.GONE); binding.detailSecondaryControlPanel.setVisibility(View.GONE);
sortedVideoStreams = ListHelper.getSortedStreamVideosList(
activity,
info.getVideoStreams(),
info.getVideoOnlyStreams(),
false);
selectedVideoStreamIndex = ListHelper
.getDefaultResolutionIndex(activity, sortedVideoStreams);
updateProgressInfo(info); updateProgressInfo(info);
initThumbnailViews(info); initThumbnailViews(info);
showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView,
@ -1667,8 +1667,8 @@ public final class VideoDetailFragment
} }
} }
binding.detailControlsDownload.setVisibility(info.getStreamType() == StreamType.LIVE_STREAM binding.detailControlsDownload.setVisibility(
|| info.getStreamType() == StreamType.AUDIO_LIVE_STREAM ? View.GONE : View.VISIBLE); StreamTypeUtil.isLiveStream(info.getStreamType()) ? View.GONE : View.VISIBLE);
binding.detailControlsBackground.setVisibility(info.getAudioStreams().isEmpty() binding.detailControlsBackground.setVisibility(info.getAudioStreams().isEmpty()
? View.GONE : View.VISIBLE); ? View.GONE : View.VISIBLE);
@ -1726,18 +1726,12 @@ public final class VideoDetailFragment
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe(videoSegments -> { .subscribe(videoSegments -> {
try { try {
final DownloadDialog downloadDialog = DownloadDialog.newInstance(currentInfo); final DownloadDialog downloadDialog = new DownloadDialog(activity, currentInfo);
downloadDialog.setVideoStreams(sortedVideoStreams);
downloadDialog.setAudioStreams(currentInfo.getAudioStreams());
downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex);
downloadDialog.setSubtitleStreams(currentInfo.getSubtitles());
downloadDialog.setVideoSegments(videoSegments); downloadDialog.setVideoSegments(videoSegments);
downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog"); downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog");
} catch (final Exception e) { } catch (final Exception e) {
ErrorUtil.showSnackbar(activity, ErrorUtil.showSnackbar(activity, new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG,
new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG, "Showing download dialog", currentInfo));
"Showing download dialog",
currentInfo));
} }
}); });
} }
@ -1764,8 +1758,7 @@ public final class VideoDetailFragment
binding.detailPositionView.setVisibility(View.GONE); binding.detailPositionView.setVisibility(View.GONE);
// TODO: Remove this check when separation of concerns is done. // TODO: Remove this check when separation of concerns is done.
// (live streams weren't getting updated because they are mixed) // (live streams weren't getting updated because they are mixed)
if (!info.getStreamType().equals(StreamType.LIVE_STREAM) if (!StreamTypeUtil.isLiveStream(info.getStreamType())) {
&& !info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) {
return; return;
} }
} else { } else {
@ -1925,9 +1918,8 @@ public final class VideoDetailFragment
} }
@Override @Override
public void onPlayerError(final ExoPlaybackException error) { public void onPlayerError(final PlaybackException error, final boolean isCatchableException) {
if (error.type == ExoPlaybackException.TYPE_SOURCE if (!isCatchableException) {
|| error.type == ExoPlaybackException.TYPE_UNEXPECTED) {
// Properly exit from fullscreen // Properly exit from fullscreen
toggleFullscreenIfInFullscreenMode(); toggleFullscreenIfInFullscreenMode();
hideMainPlayerOnLoadingNewStream(); hideMainPlayerOnLoadingNewStream();
@ -2194,25 +2186,52 @@ public final class VideoDetailFragment
} }
private void showExternalPlaybackDialog() { private void showExternalPlaybackDialog() {
if (sortedVideoStreams == null) { if (currentInfo == null) {
return; return;
} }
final CharSequence[] resolutions = new CharSequence[sortedVideoStreams.size()];
for (int i = 0; i < sortedVideoStreams.size(); i++) { final AlertDialog.Builder builder = new AlertDialog.Builder(activity);
resolutions[i] = sortedVideoStreams.get(i).getResolution(); builder.setTitle(R.string.select_quality_external_players);
} builder.setNeutralButton(R.string.open_in_browser, (dialog, i) ->
final AlertDialog.Builder builder = new AlertDialog.Builder(activity) ShareUtils.openUrlInBrowser(requireActivity(), url));
.setNegativeButton(R.string.cancel, null)
.setNeutralButton(R.string.open_in_browser, (dialog, i) -> final List<VideoStream> videoStreamsForExternalPlayers =
ShareUtils.openUrlInBrowser(requireActivity(), url) ListHelper.getSortedStreamVideosList(
activity,
getUrlAndNonTorrentStreams(currentInfo.getVideoStreams()),
getUrlAndNonTorrentStreams(currentInfo.getVideoOnlyStreams()),
false,
false
); );
// Maybe there are no video streams available, show just `open in browser` button
if (resolutions.length > 0) { if (videoStreamsForExternalPlayers.isEmpty()) {
builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndex, (dialog, i) -> { builder.setMessage(R.string.no_video_streams_available_for_external_players);
dialog.dismiss(); builder.setPositiveButton(R.string.ok, null);
startOnExternalPlayer(activity, currentInfo, sortedVideoStreams.get(i));
} } else {
); final int selectedVideoStreamIndexForExternalPlayers =
ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers);
final CharSequence[] resolutions =
new CharSequence[videoStreamsForExternalPlayers.size()];
for (int i = 0; i < videoStreamsForExternalPlayers.size(); i++) {
resolutions[i] = videoStreamsForExternalPlayers.get(i).getResolution();
}
builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndexForExternalPlayers,
null);
builder.setNegativeButton(R.string.cancel, null);
builder.setPositiveButton(R.string.ok, (dialog, i) -> {
final int index = ((AlertDialog) dialog).getListView().getCheckedItemPosition();
// We don't have to manage the index validity because if there is no stream
// available for external players, this code will be not executed and if there is
// no stream which matches the default resolution, 0 is returned by
// ListHelper.getDefaultResolutionIndex.
// The index cannot be outside the bounds of the list as its always between 0 and
// the list size - 1, .
startOnExternalPlayer(activity, currentInfo,
videoStreamsForExternalPlayers.get(index));
});
} }
builder.show(); builder.show();
} }

View file

@ -1,5 +1,9 @@
package org.schabi.newpipe.fragments.detail; package org.schabi.newpipe.fragments.detail;
import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW;
import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_DECODING_FAILED;
import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_UNSPECIFIED;
import android.content.Context; import android.content.Context;
import android.util.Log; import android.util.Log;
import android.view.ContextThemeWrapper; import android.view.ContextThemeWrapper;
@ -15,6 +19,7 @@ import androidx.appcompat.app.AlertDialog;
import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.PlaybackException;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.ListRadioIconItemBinding; import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
@ -51,7 +56,8 @@ public final class VideoDetailPlayerCrasher {
exceptionTypes.put( exceptionTypes.put(
"Source", "Source",
() -> ExoPlaybackException.createForSource( () -> ExoPlaybackException.createForSource(
new IOException(defaultMsg) new IOException(defaultMsg),
ERROR_CODE_BEHIND_LIVE_WINDOW
) )
); );
exceptionTypes.put( exceptionTypes.put(
@ -61,13 +67,16 @@ public final class VideoDetailPlayerCrasher {
"Dummy renderer", "Dummy renderer",
0, 0,
null, null,
C.FORMAT_HANDLED C.FORMAT_HANDLED,
/*isRecoverable=*/false,
ERROR_CODE_DECODING_FAILED
) )
); );
exceptionTypes.put( exceptionTypes.put(
"Unexpected", "Unexpected",
() -> ExoPlaybackException.createForUnexpected( () -> ExoPlaybackException.createForUnexpected(
new RuntimeException(defaultMsg) new RuntimeException(defaultMsg),
ERROR_CODE_UNSPECIFIED
) )
); );
exceptionTypes.put( exceptionTypes.put(
@ -88,8 +97,7 @@ public final class VideoDetailPlayerCrasher {
public static void onCrashThePlayer( public static void onCrashThePlayer(
@NonNull final Context context, @NonNull final Context context,
@Nullable final Player player, @Nullable final Player player
@NonNull final LayoutInflater layoutInflater
) { ) {
if (player == null) { if (player == null) {
Log.d(TAG, "Player is not available"); Log.d(TAG, "Player is not available");
@ -100,16 +108,15 @@ public final class VideoDetailPlayerCrasher {
} }
// -- Build the dialog/UI -- // -- Build the dialog/UI --
final Context themeWrapperContext = getThemeWrapperContext(context); final Context themeWrapperContext = getThemeWrapperContext(context);
final LayoutInflater inflater = LayoutInflater.from(themeWrapperContext); final LayoutInflater inflater = LayoutInflater.from(themeWrapperContext);
final RadioGroup radioGroup = SingleChoiceDialogViewBinding.inflate(layoutInflater)
.list;
final AlertDialog alertDialog = new AlertDialog.Builder(getThemeWrapperContext(context)) final SingleChoiceDialogViewBinding binding =
SingleChoiceDialogViewBinding.inflate(inflater);
final AlertDialog alertDialog = new AlertDialog.Builder(themeWrapperContext)
.setTitle("Choose an exception") .setTitle("Choose an exception")
.setView(radioGroup) .setView(binding.getRoot())
.setCancelable(true) .setCancelable(true)
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.create(); .create();
@ -127,11 +134,9 @@ public final class VideoDetailPlayerCrasher {
); );
radioButton.setOnClickListener(v -> { radioButton.setOnClickListener(v -> {
tryCrashPlayerWith(player, entry.getValue().get()); tryCrashPlayerWith(player, entry.getValue().get());
if (alertDialog != null) { alertDialog.cancel();
alertDialog.cancel();
}
}); });
radioGroup.addView(radioButton); binding.list.addView(radioButton);
} }
alertDialog.show(); alertDialog.show();
@ -139,7 +144,7 @@ public final class VideoDetailPlayerCrasher {
/** /**
* Note that this method does not crash the underlying exoplayer directly (it's not possible). * Note that this method does not crash the underlying exoplayer directly (it's not possible).
* It simply supplies a Exception to {@link Player#onPlayerError(ExoPlaybackException)}. * It simply supplies a Exception to {@link Player#onPlayerError(PlaybackException)}.
* @param player * @param player
* @param exception * @param exception
*/ */

View file

@ -1,6 +1,8 @@
package org.schabi.newpipe.fragments.list; package org.schabi.newpipe.fragments.list;
import android.app.Activity; import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.content.res.Configuration; import android.content.res.Configuration;
@ -25,29 +27,19 @@ import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.BaseStateFragment;
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
import org.schabi.newpipe.info_list.InfoItemDialog; import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.InfoListAdapter; import org.schabi.newpipe.info_list.InfoListAdapter;
import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.util.external_communication.KoreUtils;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.util.StateSaver; import org.schabi.newpipe.util.StateSaver;
import org.schabi.newpipe.util.StreamDialogEntry;
import org.schabi.newpipe.views.SuperScrollLayoutManager; import org.schabi.newpipe.views.SuperScrollLayoutManager;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Queue; import java.util.Queue;
import java.util.function.Supplier; import java.util.function.Supplier;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
implements ListViewContract<I, N>, StateSaver.WriteRead, implements ListViewContract<I, N>, StateSaver.WriteRead,
SharedPreferences.OnSharedPreferenceChangeListener { SharedPreferences.OnSharedPreferenceChangeListener {
@ -268,11 +260,11 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
@Override @Override
public void held(final StreamInfoItem selectedItem) { public void held(final StreamInfoItem selectedItem) {
showStreamDialog(selectedItem); showInfoItemDialog(selectedItem);
} }
}); });
infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<ChannelInfoItem>() { infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<>() {
@Override @Override
public void selected(final ChannelInfoItem selectedItem) { public void selected(final ChannelInfoItem selectedItem) {
try { try {
@ -288,7 +280,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
} }
}); });
infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture<PlaylistInfoItem>() { infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture<>() {
@Override @Override
public void selected(final PlaylistInfoItem selectedItem) { public void selected(final PlaylistInfoItem selectedItem) {
try { try {
@ -350,7 +342,8 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
itemsList.clearOnScrollListeners(); itemsList.clearOnScrollListeners();
itemsList.addOnScrollListener(new DefaultItemListOnScrolledDownListener() { itemsList.addOnScrollListener(new DefaultItemListOnScrolledDownListener() {
@Override @Override
public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) { public void onScrolled(@NonNull final RecyclerView recyclerView,
final int dx, final int dy) {
super.onScrolled(recyclerView, dx, dy); super.onScrolled(recyclerView, dx, dy);
if (dy != 0) { if (dy != 0) {
@ -409,55 +402,12 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
} }
} }
protected void showStreamDialog(final StreamInfoItem item) { protected void showInfoItemDialog(final StreamInfoItem item) {
final Context context = getContext(); try {
final Activity activity = getActivity(); new InfoItemDialog.Builder(getActivity(), getContext(), this, item).create().show();
if (context == null || context.getResources() == null || activity == null) { } catch (final IllegalArgumentException e) {
return; InfoItemDialog.Builder.reportErrorDuringInitialization(e, item);
} }
final List<StreamDialogEntry> entries = new ArrayList<>();
if (PlayerHolder.getInstance().isPlayQueueReady()) {
entries.add(StreamDialogEntry.enqueue);
if (PlayerHolder.getInstance().getQueueSize() > 1) {
entries.add(StreamDialogEntry.enqueue_next);
}
}
if (item.getStreamType() == StreamType.AUDIO_STREAM) {
entries.addAll(Arrays.asList(
StreamDialogEntry.start_here_on_background,
StreamDialogEntry.append_playlist,
StreamDialogEntry.share
));
} else {
entries.addAll(Arrays.asList(
StreamDialogEntry.start_here_on_background,
StreamDialogEntry.start_here_on_popup,
StreamDialogEntry.append_playlist,
StreamDialogEntry.share
));
}
entries.add(StreamDialogEntry.open_in_browser);
if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) {
entries.add(StreamDialogEntry.play_with_kodi);
}
// show "mark as watched" only when watch history is enabled
if (StreamDialogEntry.shouldAddMarkAsWatched(item.getStreamType(), context)) {
entries.add(
StreamDialogEntry.mark_as_watched
);
}
if (!isNullOrEmpty(item.getUploaderUrl())) {
entries.add(StreamDialogEntry.show_channel_details);
}
StreamDialogEntry.setEnabledEntries(entries);
new InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context),
(dialog, which) -> StreamDialogEntry.clickOn(which, this, item)).show();
} }
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////

View file

@ -9,6 +9,7 @@ import androidx.annotation.NonNull;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.Page;
@ -27,8 +28,8 @@ import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.schedulers.Schedulers;
public abstract class BaseListInfoFragment<I extends ListInfo> public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInfo<I>>
extends BaseListFragment<I, ListExtractor.InfoItemsPage> { extends BaseListFragment<L, ListExtractor.InfoItemsPage<I>> {
@State @State
protected int serviceId = Constants.NO_SERVICE_ID; protected int serviceId = Constants.NO_SERVICE_ID;
@State @State
@ -37,7 +38,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
protected String url; protected String url;
private final UserAction errorUserAction; private final UserAction errorUserAction;
protected I currentInfo; protected L currentInfo;
protected Page currentNextPage; protected Page currentNextPage;
protected Disposable currentWorker; protected Disposable currentWorker;
@ -97,7 +98,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public void readFrom(@NonNull final Queue<Object> savedObjects) throws Exception { public void readFrom(@NonNull final Queue<Object> savedObjects) throws Exception {
super.readFrom(savedObjects); super.readFrom(savedObjects);
currentInfo = (I) savedObjects.poll(); currentInfo = (L) savedObjects.poll();
currentNextPage = (Page) savedObjects.poll(); currentNextPage = (Page) savedObjects.poll();
} }
@ -124,7 +125,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
* @param forceLoad allow or disallow the result to come from the cache * @param forceLoad allow or disallow the result to come from the cache
* @return Rx {@link Single} containing the {@link ListInfo} * @return Rx {@link Single} containing the {@link ListInfo}
*/ */
protected abstract Single<I> loadResult(boolean forceLoad); protected abstract Single<L> loadResult(boolean forceLoad);
@Override @Override
public void startLoading(final boolean forceLoad) { public void startLoading(final boolean forceLoad) {
@ -140,7 +141,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
currentWorker = loadResult(forceLoad) currentWorker = loadResult(forceLoad)
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe((@NonNull I result) -> { .subscribe((@NonNull L result) -> {
isLoading.set(false); isLoading.set(false);
currentInfo = result; currentInfo = result;
currentNextPage = result.getNextPage(); currentNextPage = result.getNextPage();
@ -157,7 +158,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
* *
* @return Rx {@link Single} containing the {@link ListExtractor.InfoItemsPage} * @return Rx {@link Single} containing the {@link ListExtractor.InfoItemsPage}
*/ */
protected abstract Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic(); protected abstract Single<ListExtractor.InfoItemsPage<I>> loadMoreItemsLogic();
@Override @Override
protected void loadMoreItems() { protected void loadMoreItems() {
@ -194,7 +195,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
} }
@Override @Override
public void handleNextItems(final ListExtractor.InfoItemsPage result) { public void handleNextItems(final ListExtractor.InfoItemsPage<I> result) {
super.handleNextItems(result); super.handleNextItems(result);
currentNextPage = result.getNextPage(); currentNextPage = result.getNextPage();
@ -218,7 +219,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override @Override
public void handleResult(@NonNull final I result) { public void handleResult(@NonNull final L result) {
super.handleResult(result); super.handleResult(result);
name = result.getName(); name = result.getName();

View file

@ -5,6 +5,7 @@ import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor; import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
import android.content.Context; import android.content.Context;
import android.graphics.Color;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
@ -22,9 +23,11 @@ import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBar;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import com.google.android.material.snackbar.Snackbar;
import com.jakewharton.rxbinding4.view.RxView; import com.jakewharton.rxbinding4.view.RxView;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.NotificationMode;
import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.databinding.ChannelHeaderBinding; import org.schabi.newpipe.databinding.ChannelHeaderBinding;
import org.schabi.newpipe.databinding.FragmentChannelBinding; import org.schabi.newpipe.databinding.FragmentChannelBinding;
@ -39,6 +42,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.local.subscription.SubscriptionManager; import org.schabi.newpipe.local.subscription.SubscriptionManager;
import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
import org.schabi.newpipe.player.MainPlayer.PlayerType; import org.schabi.newpipe.player.MainPlayer.PlayerType;
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue; import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
@ -64,7 +68,7 @@ import io.reactivex.rxjava3.functions.Consumer;
import io.reactivex.rxjava3.functions.Function; import io.reactivex.rxjava3.functions.Function;
import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.schedulers.Schedulers;
public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, ChannelInfo>
implements View.OnClickListener { implements View.OnClickListener {
private static final int BUTTON_DEBOUNCE_INTERVAL = 100; private static final int BUTTON_DEBOUNCE_INTERVAL = 100;
@ -73,6 +77,8 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
private final CompositeDisposable disposables = new CompositeDisposable(); private final CompositeDisposable disposables = new CompositeDisposable();
private Disposable subscribeButtonMonitor; private Disposable subscribeButtonMonitor;
private boolean channelContentNotSupported = false;
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Views // Views
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@ -84,6 +90,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
private PlaylistControlBinding playlistControlBinding; private PlaylistControlBinding playlistControlBinding;
private MenuItem menuRssButton; private MenuItem menuRssButton;
private MenuItem menuNotifyButton;
public static ChannelFragment getInstance(final int serviceId, final String url, public static ChannelFragment getInstance(final int serviceId, final String url,
final String name) { final String name) {
@ -125,6 +132,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
super.onViewCreated(rootView, savedInstanceState); super.onViewCreated(rootView, savedInstanceState);
channelBinding = FragmentChannelBinding.bind(rootView); channelBinding = FragmentChannelBinding.bind(rootView);
showContentNotSupportedIfNeeded();
} }
@Override @Override
@ -179,6 +187,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
+ "menu = [" + menu + "], inflater = [" + inflater + "]"); + "menu = [" + menu + "], inflater = [" + inflater + "]");
} }
menuRssButton = menu.findItem(R.id.menu_item_rss); menuRssButton = menu.findItem(R.id.menu_item_rss);
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
} }
} }
@ -188,6 +197,11 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
case R.id.action_settings: case R.id.action_settings:
NavigationHelper.openSettings(requireContext()); NavigationHelper.openSettings(requireContext());
break; break;
case R.id.menu_item_notify:
final boolean value = !item.isChecked();
item.setEnabled(false);
setNotify(value);
break;
case R.id.menu_item_rss: case R.id.menu_item_rss:
if (currentInfo != null) { if (currentInfo != null) {
ShareUtils.openUrlInBrowser( ShareUtils.openUrlInBrowser(
@ -232,15 +246,22 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
.subscribe(getSubscribeUpdateMonitor(info), onError)); .subscribe(getSubscribeUpdateMonitor(info), onError));
disposables.add(observable disposables.add(observable
// Some updates are very rapid .map(List::isEmpty)
// (for example when calling the updateSubscription(info)) .distinctUntilChanged()
// so only update the UI for the latest emission
// ("sync" the subscribe button's state)
.debounce(100, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe((List<SubscriptionEntity> subscriptionEntities) -> .subscribe(isEmpty -> updateSubscribeButton(!isEmpty), onError));
updateSubscribeButton(!subscriptionEntities.isEmpty()), onError));
disposables.add(observable
.map(List::isEmpty)
.distinctUntilChanged()
.skip(1) // channel has just been opened
.filter(x -> NotificationHelper.areNewStreamsNotificationsEnabled(requireContext()))
.observeOn(AndroidSchedulers.mainThread())
.subscribe(isEmpty -> {
if (!isEmpty) {
showNotifySnackbar();
}
}, onError));
} }
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription, private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription,
@ -320,6 +341,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
info.getAvatarUrl(), info.getAvatarUrl(),
info.getDescription(), info.getDescription(),
info.getSubscriberCount()); info.getSubscriberCount());
updateNotifyButton(null);
subscribeButtonMonitor = monitorSubscribeButton( subscribeButtonMonitor = monitorSubscribeButton(
headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info)); headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info));
} else { } else {
@ -327,6 +349,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
Log.d(TAG, "Found subscription to this channel!"); Log.d(TAG, "Found subscription to this channel!");
} }
final SubscriptionEntity subscription = subscriptionEntities.get(0); final SubscriptionEntity subscription = subscriptionEntities.get(0);
updateNotifyButton(subscription);
subscribeButtonMonitor = monitorSubscribeButton( subscribeButtonMonitor = monitorSubscribeButton(
headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription)); headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription));
} }
@ -369,12 +392,51 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
AnimationType.LIGHT_SCALE_AND_ALPHA); AnimationType.LIGHT_SCALE_AND_ALPHA);
} }
private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) {
if (menuNotifyButton == null) {
return;
}
if (subscription != null) {
menuNotifyButton.setEnabled(
NotificationHelper.areNewStreamsNotificationsEnabled(requireContext())
);
menuNotifyButton.setChecked(
subscription.getNotificationMode() == NotificationMode.ENABLED
);
}
menuNotifyButton.setVisible(subscription != null);
}
private void setNotify(final boolean isEnabled) {
disposables.add(
subscriptionManager
.updateNotificationMode(
currentInfo.getServiceId(),
currentInfo.getUrl(),
isEnabled ? NotificationMode.ENABLED : NotificationMode.DISABLED)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe()
);
}
/**
* Show a snackbar with the option to enable notifications on new streams for this channel.
*/
private void showNotifySnackbar() {
Snackbar.make(itemsList, R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG)
.setAction(R.string.get_notified, v -> setNotify(true))
.setActionTextColor(Color.YELLOW)
.show();
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Load and handle // Load and handle
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override @Override
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() { protected Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPage); return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPage);
} }
@ -465,9 +527,12 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
playlistControlBinding.getRoot().setVisibility(View.GONE); playlistControlBinding.getRoot().setVisibility(View.GONE);
} }
channelContentNotSupported = false;
for (final Throwable throwable : result.getErrors()) { for (final Throwable throwable : result.getErrors()) {
if (throwable instanceof ContentNotSupportedException) { if (throwable instanceof ContentNotSupportedException) {
showContentNotSupported(); channelContentNotSupported = true;
showContentNotSupportedIfNeeded();
break;
} }
} }
@ -499,7 +564,13 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
}); });
} }
private void showContentNotSupported() { private void showContentNotSupportedIfNeeded() {
// channelBinding might not be initialized when handleResult() is called
// (e.g. after rotating the screen, #6696)
if (!channelContentNotSupported || channelBinding == null) {
return;
}
channelBinding.errorContentNotSupported.setVisibility(View.VISIBLE); channelBinding.errorContentNotSupported.setVisibility(View.VISIBLE);
channelBinding.channelKaomoji.setText("(︶︹︺)"); channelBinding.channelKaomoji.setText("(︶︹︺)");
channelBinding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f); channelBinding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f);

View file

@ -15,6 +15,7 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.comments.CommentsInfo; import org.schabi.newpipe.extractor.comments.CommentsInfo;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ExtractorHelper;
@ -22,7 +23,7 @@ import org.schabi.newpipe.util.ExtractorHelper;
import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
public class CommentsFragment extends BaseListInfoFragment<CommentsInfo> { public class CommentsFragment extends BaseListInfoFragment<CommentsInfoItem, CommentsInfo> {
private final CompositeDisposable disposables = new CompositeDisposable(); private final CompositeDisposable disposables = new CompositeDisposable();
private TextView emptyStateDesc; private TextView emptyStateDesc;
@ -67,7 +68,7 @@ public class CommentsFragment extends BaseListInfoFragment<CommentsInfo> {
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override @Override
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() { protected Single<ListExtractor.InfoItemsPage<CommentsInfoItem>> loadMoreItemsLogic() {
return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage); return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage);
} }

View file

@ -21,6 +21,7 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.kiosk.KioskInfo; import org.schabi.newpipe.extractor.kiosk.KioskInfo;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory;
import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.ContentCountry;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.KioskTranslator; import org.schabi.newpipe.util.KioskTranslator;
@ -53,7 +54,7 @@ import io.reactivex.rxjava3.core.Single;
* </p> * </p>
*/ */
public class KioskFragment extends BaseListInfoFragment<KioskInfo> { public class KioskFragment extends BaseListInfoFragment<StreamInfoItem, KioskInfo> {
@State @State
String kioskId = ""; String kioskId = "";
String kioskTranslatedName; String kioskTranslatedName;
@ -145,7 +146,7 @@ public class KioskFragment extends BaseListInfoFragment<KioskInfo> {
} }
@Override @Override
public Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() { public Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
return ExtractorHelper.getMoreKioskItems(serviceId, url, currentNextPage); return ExtractorHelper.getMoreKioskItems(serviceId, url, currentNextPage);
} }

View file

@ -1,10 +1,8 @@
package org.schabi.newpipe.fragments.list.playlist; package org.schabi.newpipe.fragments.list.playlist;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
import android.app.Activity;
import android.content.Context; import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils; import android.text.TextUtils;
@ -20,11 +18,15 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.content.res.AppCompatResources;
import com.google.android.material.shape.CornerFamily;
import com.google.android.material.shape.ShapeAppearanceModel;
import org.reactivestreams.Subscriber; import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription; import org.reactivestreams.Subscription;
import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.databinding.PlaylistControlBinding; import org.schabi.newpipe.databinding.PlaylistControlBinding;
import org.schabi.newpipe.databinding.PlaylistHeaderBinding; import org.schabi.newpipe.databinding.PlaylistHeaderBinding;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
@ -36,27 +38,25 @@ import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper; import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper;
import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.info_list.InfoItemDialog; import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.local.playlist.RemotePlaylistManager; import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
import org.schabi.newpipe.player.MainPlayer.PlayerType; import org.schabi.newpipe.player.MainPlayer.PlayerType;
import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.StreamDialogEntry;
import org.schabi.newpipe.util.external_communication.KoreUtils;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier; import java.util.function.Supplier;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Flowable; import io.reactivex.rxjava3.core.Flowable;
@ -64,7 +64,7 @@ import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> { public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, PlaylistInfo> {
private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG"; private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG";
@ -140,60 +140,22 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
} }
@Override @Override
protected void showStreamDialog(final StreamInfoItem item) { protected void showInfoItemDialog(final StreamInfoItem item) {
final Context context = getContext(); final Context context = getContext();
final Activity activity = getActivity(); try {
if (context == null || context.getResources() == null || activity == null) { final InfoItemDialog.Builder dialogBuilder =
return; new InfoItemDialog.Builder(getActivity(), context, this, item);
dialogBuilder
.setAction(
StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND,
(f, infoItem) -> NavigationHelper.playOnBackgroundPlayer(
context, getPlayQueueStartingAt(infoItem), true))
.create()
.show();
} catch (final IllegalArgumentException e) {
InfoItemDialog.Builder.reportErrorDuringInitialization(e, item);
} }
final ArrayList<StreamDialogEntry> entries = new ArrayList<>();
if (PlayerHolder.getInstance().isPlayQueueReady()) {
entries.add(StreamDialogEntry.enqueue);
if (PlayerHolder.getInstance().getQueueSize() > 1) {
entries.add(StreamDialogEntry.enqueue_next);
}
}
if (item.getStreamType() == StreamType.AUDIO_STREAM) {
entries.addAll(Arrays.asList(
StreamDialogEntry.start_here_on_background,
StreamDialogEntry.append_playlist,
StreamDialogEntry.share
));
} else {
entries.addAll(Arrays.asList(
StreamDialogEntry.start_here_on_background,
StreamDialogEntry.start_here_on_popup,
StreamDialogEntry.append_playlist,
StreamDialogEntry.share
));
}
entries.add(StreamDialogEntry.open_in_browser);
if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) {
entries.add(StreamDialogEntry.play_with_kodi);
}
// show "mark as watched" only when watch history is enabled
if (StreamDialogEntry.shouldAddMarkAsWatched(item.getStreamType(), context)) {
entries.add(
StreamDialogEntry.mark_as_watched
);
}
if (!isNullOrEmpty(item.getUploaderUrl())) {
entries.add(StreamDialogEntry.show_channel_details);
}
StreamDialogEntry.setEnabledEntries(entries);
StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItem) ->
NavigationHelper.playOnBackgroundPlayer(context,
getPlayQueueStartingAt(infoItem), true));
new InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context),
(dialog, which) -> StreamDialogEntry.clickOn(which, this, item)).show();
} }
@Override @Override
@ -249,7 +211,7 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override @Override
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() { protected Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPage); return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPage);
} }
@ -276,6 +238,17 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
case R.id.menu_item_bookmark: case R.id.menu_item_bookmark:
onBookmarkClicked(); onBookmarkClicked();
break; break;
case R.id.menu_item_append_playlist:
disposables.add(PlaylistDialog.createCorrespondingDialog(
getContext(),
getPlayQueue()
.getStreams()
.stream()
.map(StreamEntity::new)
.collect(Collectors.toList()),
dialog -> dialog.show(getFM(), TAG)
));
break;
default: default:
return super.onOptionsItemSelected(item); return super.onOptionsItemSelected(item);
} }
@ -328,9 +301,12 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
&& (YoutubeParsingHelper.isYoutubeMixId(result.getId()) && (YoutubeParsingHelper.isYoutubeMixId(result.getId())
|| YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) { || YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) {
// this is an auto-generated playlist (e.g. Youtube mix), so a radio is shown // this is an auto-generated playlist (e.g. Youtube mix), so a radio is shown
headerBinding.uploaderAvatarView.setDisableCircularTransformation(true); final ShapeAppearanceModel model = ShapeAppearanceModel.builder()
headerBinding.uploaderAvatarView.setBorderColor( .setAllCorners(CornerFamily.ROUNDED, 0f)
getResources().getColor(R.color.transparent_background_color)); .build(); // this turns the image back into a square
headerBinding.uploaderAvatarView.setShapeAppearanceModel(model);
headerBinding.uploaderAvatarView.setStrokeColor(AppCompatResources
.getColorStateList(requireContext(), R.color.transparent_background_color));
headerBinding.uploaderAvatarView.setImageDrawable( headerBinding.uploaderAvatarView.setImageDrawable(
AppCompatResources.getDrawable(requireContext(), AppCompatResources.getDrawable(requireContext(),
R.drawable.ic_radio) R.drawable.ic_radio)

View file

@ -7,6 +7,7 @@ import android.view.ViewGroup;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
@ -34,8 +35,10 @@ public class SuggestionListAdapter
this.listener = listener; this.listener = listener;
} }
@NonNull
@Override @Override
public SuggestionItemHolder onCreateViewHolder(final ViewGroup parent, final int viewType) { public SuggestionItemHolder onCreateViewHolder(@NonNull final ViewGroup parent,
final int viewType) {
return new SuggestionItemHolder(LayoutInflater.from(context) return new SuggestionItemHolder(LayoutInflater.from(context)
.inflate(R.layout.item_search_suggestion, parent, false)); .inflate(R.layout.item_search_suggestion, parent, false));
} }

View file

@ -15,6 +15,7 @@ import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.RelatedItemsHeaderBinding; import org.schabi.newpipe.databinding.RelatedItemsHeaderBinding;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
@ -26,7 +27,7 @@ import java.util.function.Supplier;
import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.core.Single;
public class RelatedItemsFragment extends BaseListInfoFragment<RelatedItemInfo> public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, RelatedItemInfo>
implements SharedPreferences.OnSharedPreferenceChangeListener { implements SharedPreferences.OnSharedPreferenceChangeListener {
private static final String INFO_KEY = "related_info_key"; private static final String INFO_KEY = "related_info_key";
@ -86,7 +87,7 @@ public class RelatedItemsFragment extends BaseListInfoFragment<RelatedItemInfo>
} }
@Override @Override
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() { protected Single<ListExtractor.InfoItemsPage<InfoItem>> loadMoreItemsLogic() {
return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage); return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage);
} }

View file

@ -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();
}
}

View file

@ -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()));
}
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -1,6 +1,7 @@
package org.schabi.newpipe.info_list.holder; package org.schabi.newpipe.info_list.holder;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView; import android.widget.TextView;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
@ -11,10 +12,8 @@ import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import de.hdodenhof.circleimageview.CircleImageView;
public class ChannelMiniInfoItemHolder extends InfoItemHolder { public class ChannelMiniInfoItemHolder extends InfoItemHolder {
public final CircleImageView itemThumbnailView; public final ImageView itemThumbnailView;
public final TextView itemTitleView; public final TextView itemTitleView;
private final TextView itemAdditionalDetailView; private final TextView itemAdditionalDetailView;

View file

@ -7,6 +7,7 @@ import android.text.util.Linkify;
import android.util.Log; import android.util.Log;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.RelativeLayout; import android.widget.RelativeLayout;
import android.widget.TextView; import android.widget.TextView;
@ -28,8 +29,6 @@ import org.schabi.newpipe.util.PicassoHelper;
import java.util.regex.Matcher; import java.util.regex.Matcher;
import de.hdodenhof.circleimageview.CircleImageView;
public class CommentsMiniInfoItemHolder extends InfoItemHolder { public class CommentsMiniInfoItemHolder extends InfoItemHolder {
private static final String TAG = "CommentsMiniIIHolder"; private static final String TAG = "CommentsMiniIIHolder";
@ -40,7 +39,7 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
private final int commentVerticalPadding; private final int commentVerticalPadding;
private final RelativeLayout itemRoot; private final RelativeLayout itemRoot;
public final CircleImageView itemThumbnailView; public final ImageView itemThumbnailView;
private final TextView itemContentView; private final TextView itemContentView;
private final TextView itemLikesCountView; private final TextView itemLikesCountView;
private final TextView itemPublishedTime; private final TextView itemPublishedTime;

View file

@ -11,12 +11,12 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.database.stream.model.StreamStateEntity;
import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.StreamTypeUtil;
import org.schabi.newpipe.views.AnimatedProgressBar; import org.schabi.newpipe.views.AnimatedProgressBar;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -70,8 +70,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
} else { } else {
itemProgressView.setVisibility(View.GONE); itemProgressView.setVisibility(View.GONE);
} }
} else if (item.getStreamType() == StreamType.LIVE_STREAM } else if (StreamTypeUtil.isLiveStream(item.getStreamType())) {
|| item.getStreamType() == StreamType.AUDIO_LIVE_STREAM) {
itemDurationView.setText(R.string.duration_live); itemDurationView.setText(R.string.duration_live);
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
R.color.live_duration_background_color)); R.color.live_duration_background_color));
@ -96,9 +95,10 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
case VIDEO_STREAM: case VIDEO_STREAM:
case LIVE_STREAM: case LIVE_STREAM:
case AUDIO_LIVE_STREAM: case AUDIO_LIVE_STREAM:
case POST_LIVE_STREAM:
case POST_LIVE_AUDIO_STREAM:
enableLongClick(item); enableLongClick(item);
break; break;
case FILE:
case NONE: case NONE:
default: default:
disableLongClick(); disableLongClick();
@ -114,7 +114,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
final StreamStateEntity state final StreamStateEntity state
= historyRecordManager.loadStreamState(infoItem).blockingGet()[0]; = historyRecordManager.loadStreamState(infoItem).blockingGet()[0];
if (state != null && item.getDuration() > 0 if (state != null && item.getDuration() > 0
&& item.getStreamType() != StreamType.LIVE_STREAM) { && !StreamTypeUtil.isLiveStream(item.getStreamType())) {
itemProgressView.setMax((int) item.getDuration()); itemProgressView.setMax((int) item.getDuration());
if (itemProgressView.getVisibility() == View.VISIBLE) { if (itemProgressView.getVisibility() == View.VISIBLE) {
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS

View file

@ -300,14 +300,7 @@ private fun View.animateLightSlideAndAlpha(enterOrExit: Boolean, duration: Long,
} }
} }
fun View.slideUp( @JvmOverloads
duration: Long,
delay: Long,
@FloatRange(from = 0.0, to = 1.0) translationPercent: Float
) {
slideUp(duration, delay, translationPercent, null)
}
fun View.slideUp( fun View.slideUp(
duration: Long, duration: Long,
delay: Long = 0L, delay: Long = 0L,

View file

@ -228,6 +228,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
return count; return count;
} }
@SuppressWarnings("FinalParameters")
@Override @Override
public int getItemViewType(int position) { public int getItemViewType(int position) {
if (DEBUG) { if (DEBUG) {
@ -300,6 +301,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
} }
} }
@SuppressWarnings("FinalParameters")
@Override @Override
public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int position) { public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int position) {
if (DEBUG) { if (DEBUG) {

View file

@ -33,8 +33,16 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
private final CompositeDisposable playlistDisposables = new CompositeDisposable(); private final CompositeDisposable playlistDisposables = new CompositeDisposable();
public PlaylistAppendDialog(final List<StreamEntity> streamEntities) { /**
super(streamEntities); * Create a new instance of {@link PlaylistAppendDialog}.
*
* @param streamEntities a list of {@link StreamEntity} to be added to playlists
* @return a new instance of {@link PlaylistAppendDialog}
*/
public static PlaylistAppendDialog newInstance(final List<StreamEntity> streamEntities) {
final PlaylistAppendDialog dialog = new PlaylistAppendDialog();
dialog.setStreamEntities(streamEntities);
return dialog;
} }
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
@ -103,13 +111,14 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
// Helper // Helper
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
/** Display create playlist dialog. */
public void openCreatePlaylistDialog() { public void openCreatePlaylistDialog() {
if (getStreamEntities() == null || !isAdded()) { if (getStreamEntities() == null || !isAdded()) {
return; return;
} }
final PlaylistCreationDialog playlistCreationDialog = final PlaylistCreationDialog playlistCreationDialog =
new PlaylistCreationDialog(getStreamEntities()); PlaylistCreationDialog.newInstance(getStreamEntities());
// Move the dismissListener to the new dialog. // Move the dismissListener to the new dialog.
playlistCreationDialog.setOnDismissListener(this.getOnDismissListener()); playlistCreationDialog.setOnDismissListener(this.getOnDismissListener());
this.setOnDismissListener(null); this.setOnDismissListener(null);

View file

@ -21,8 +21,17 @@ import java.util.List;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
public final class PlaylistCreationDialog extends PlaylistDialog { public final class PlaylistCreationDialog extends PlaylistDialog {
public PlaylistCreationDialog(final List<StreamEntity> streamEntities) {
super(streamEntities); /**
* Create a new instance of {@link PlaylistCreationDialog}.
*
* @param streamEntities a list of {@link StreamEntity} to be added to playlists
* @return a new instance of {@link PlaylistCreationDialog}
*/
public static PlaylistCreationDialog newInstance(final List<StreamEntity> streamEntities) {
final PlaylistCreationDialog dialog = new PlaylistCreationDialog();
dialog.setStreamEntities(streamEntities);
return dialog;
} }
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////

View file

@ -31,10 +31,6 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
private org.schabi.newpipe.util.SavedState savedState; private org.schabi.newpipe.util.SavedState savedState;
public PlaylistDialog(final List<StreamEntity> streamEntities) {
this.streamEntities = streamEntities;
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// LifeCycle // LifeCycle
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@ -97,7 +93,7 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
} }
@Override @Override
public void onSaveInstanceState(final Bundle outState) { public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState); super.onSaveInstanceState(outState);
if (getActivity() != null) { if (getActivity() != null) {
savedState = StateSaver.tryToSave(getActivity().isChangingConfigurations(), savedState = StateSaver.tryToSave(getActivity().isChangingConfigurations(),
@ -120,6 +116,10 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
this.onDismissListener = onDismissListener; this.onDismissListener = onDismissListener;
} }
protected void setStreamEntities(final List<StreamEntity> streamEntities) {
this.streamEntities = streamEntities;
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Dialog creation // Dialog creation
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@ -143,8 +143,8 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe(hasPlaylists -> .subscribe(hasPlaylists ->
onExec.accept(hasPlaylists onExec.accept(hasPlaylists
? new PlaylistAppendDialog(streamEntities) ? PlaylistAppendDialog.newInstance(streamEntities)
: new PlaylistCreationDialog(streamEntities)) : PlaylistCreationDialog.newInstance(streamEntities))
); );
} }
} }

View file

@ -14,6 +14,7 @@ import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
import org.schabi.newpipe.database.stream.StreamWithState import org.schabi.newpipe.database.stream.StreamWithState
import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.local.subscription.FeedGroupIcon import org.schabi.newpipe.local.subscription.FeedGroupIcon
@ -57,6 +58,11 @@ class FeedDatabaseManager(context: Context) {
fun outdatedSubscriptions(outdatedThreshold: OffsetDateTime) = feedTable.getAllOutdated(outdatedThreshold) fun outdatedSubscriptions(outdatedThreshold: OffsetDateTime) = feedTable.getAllOutdated(outdatedThreshold)
fun outdatedSubscriptionsWithNotificationMode(
outdatedThreshold: OffsetDateTime,
@NotificationMode notificationMode: Int
) = feedTable.getOutdatedWithNotificationMode(outdatedThreshold, notificationMode)
fun notLoadedCount(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable<Long> { fun notLoadedCount(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable<Long> {
return when (groupId) { return when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> feedTable.notLoadedCount() FeedGroupEntity.GROUP_ALL_ID -> feedTable.notLoadedCount()
@ -72,6 +78,10 @@ class FeedDatabaseManager(context: Context) {
fun markAsOutdated(subscriptionId: Long) = feedTable fun markAsOutdated(subscriptionId: Long) = feedTable
.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null)) .setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null))
fun doesStreamExist(stream: StreamInfoItem): Boolean {
return streamTable.exists(stream.serviceId, stream.url)
}
fun upsertAll( fun upsertAll(
subscriptionId: Long, subscriptionId: Long,
items: List<StreamInfoItem>, items: List<StreamInfoItem>,

View file

@ -25,7 +25,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.graphics.Typeface import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable import android.graphics.drawable.LayerDrawable
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
@ -37,7 +36,6 @@ import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Button import android.widget.Button
import androidx.annotation.AttrRes
import androidx.annotation.Nullable import androidx.annotation.Nullable
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
@ -50,7 +48,6 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.xwray.groupie.GroupieAdapter import com.xwray.groupie.GroupieAdapter
import com.xwray.groupie.Item import com.xwray.groupie.Item
import com.xwray.groupie.OnAsyncUpdateListener
import com.xwray.groupie.OnItemClickListener import com.xwray.groupie.OnItemClickListener
import com.xwray.groupie.OnItemLongClickListener import com.xwray.groupie.OnItemLongClickListener
import icepick.State import icepick.State
@ -68,25 +65,22 @@ import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
import org.schabi.newpipe.fragments.BaseStateFragment import org.schabi.newpipe.fragments.BaseStateFragment
import org.schabi.newpipe.info_list.InfoItemDialog import org.schabi.newpipe.info_list.dialog.InfoItemDialog
import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling
import org.schabi.newpipe.ktx.slideUp import org.schabi.newpipe.ktx.slideUp
import org.schabi.newpipe.local.feed.item.StreamItem import org.schabi.newpipe.local.feed.item.StreamItem
import org.schabi.newpipe.local.feed.service.FeedLoadService import org.schabi.newpipe.local.feed.service.FeedLoadService
import org.schabi.newpipe.local.subscription.SubscriptionManager import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.player.helper.PlayerHolder
import org.schabi.newpipe.util.DeviceUtils import org.schabi.newpipe.util.DeviceUtils
import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.StreamDialogEntry
import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams
import org.schabi.newpipe.util.ThemeHelper.resolveDrawable
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.ArrayList
import java.util.function.Consumer import java.util.function.Consumer
class FeedFragment : BaseStateFragment<FeedState>() { class FeedFragment : BaseStateFragment<FeedState>() {
@ -143,7 +137,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
val factory = FeedViewModel.Factory(requireContext(), groupId) val factory = FeedViewModel.Factory(requireContext(), groupId)
viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java) viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java)
showPlayedItems = viewModel.getShowPlayedItemsFromPreferences() showPlayedItems = viewModel.getShowPlayedItemsFromPreferences()
viewModel.stateLiveData.observe(viewLifecycleOwner, { it?.let(::handleResult) }) viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) }
groupAdapter = GroupieAdapter().apply { groupAdapter = GroupieAdapter().apply {
setOnItemClickListener(listenerStreamItem) setOnItemClickListener(listenerStreamItem)
@ -356,53 +350,12 @@ class FeedFragment : BaseStateFragment<FeedState>() {
feedBinding.loadingProgressBar.max = progressState.maxProgress feedBinding.loadingProgressBar.max = progressState.maxProgress
} }
private fun showStreamDialog(item: StreamInfoItem) { private fun showInfoItemDialog(item: StreamInfoItem) {
val context = context val context = context
val activity: Activity? = getActivity() val activity: Activity? = getActivity()
if (context == null || context.resources == null || activity == null) return if (context == null || context.resources == null || activity == null) return
val entries = ArrayList<StreamDialogEntry>() InfoItemDialog.Builder(activity, context, this, item).create().show()
if (PlayerHolder.getInstance().isPlayQueueReady) {
entries.add(StreamDialogEntry.enqueue)
if (PlayerHolder.getInstance().queueSize > 1) {
entries.add(StreamDialogEntry.enqueue_next)
}
}
if (item.streamType == StreamType.AUDIO_STREAM) {
entries.addAll(
listOf(
StreamDialogEntry.start_here_on_background,
StreamDialogEntry.append_playlist,
StreamDialogEntry.share,
StreamDialogEntry.open_in_browser
)
)
} else {
entries.addAll(
listOf(
StreamDialogEntry.start_here_on_background,
StreamDialogEntry.start_here_on_popup,
StreamDialogEntry.append_playlist,
StreamDialogEntry.share,
StreamDialogEntry.open_in_browser
)
)
}
// show "mark as watched" only when watch history is enabled
if (StreamDialogEntry.shouldAddMarkAsWatched(item.streamType, context)) {
entries.add(
StreamDialogEntry.mark_as_watched
)
}
entries.add(StreamDialogEntry.show_channel_details)
StreamDialogEntry.setEnabledEntries(entries)
InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context)) { _, which ->
StreamDialogEntry.clickOn(which, this, item)
}.show()
} }
private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener { private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener {
@ -418,7 +371,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
override fun onItemLongClick(item: Item<*>, view: View): Boolean { override fun onItemLongClick(item: Item<*>, view: View): Boolean {
if (item is StreamItem && !isRefreshing) { if (item is StreamItem && !isRefreshing) {
showStreamDialog(item.streamWithState.stream.toStreamInfoItem()) showInfoItemDialog(item.streamWithState.stream.toStreamInfoItem())
return true return true
} }
return false return false
@ -438,14 +391,11 @@ class FeedFragment : BaseStateFragment<FeedState>() {
// This need to be saved in a variable as the update occurs async // This need to be saved in a variable as the update occurs async
val oldOldestSubscriptionUpdate = oldestSubscriptionUpdate val oldOldestSubscriptionUpdate = oldestSubscriptionUpdate
groupAdapter.updateAsync( groupAdapter.updateAsync(loadedState.items, false) {
loadedState.items, false, oldOldestSubscriptionUpdate?.run {
OnAsyncUpdateListener { highlightNewItemsAfter(oldOldestSubscriptionUpdate)
oldOldestSubscriptionUpdate?.run {
highlightNewItemsAfter(oldOldestSubscriptionUpdate)
}
} }
) }
listState?.run { listState?.run {
feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState) feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState)
@ -497,8 +447,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
}.subscribeOn(Schedulers.io()) }.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
{ { subscriptionEntity ->
subscriptionEntity ->
handleFeedNotAvailable( handleFeedNotAvailable(
subscriptionEntity, subscriptionEntity,
t.cause, t.cause,
@ -629,19 +578,6 @@ class FeedFragment : BaseStateFragment<FeedState>() {
lastNewItemsCount = highlightCount lastNewItemsCount = highlightCount
} }
private fun resolveDrawable(context: Context, @AttrRes attrResId: Int): Drawable? {
return androidx.core.content.ContextCompat.getDrawable(
context,
android.util.TypedValue().apply {
context.theme.resolveAttribute(
attrResId,
this,
true
)
}.resourceId
)
}
private fun showNewItemsLoaded() { private fun showNewItemsLoaded() {
tryGetNewItemsLoadedButton()?.clearAnimation() tryGetNewItemsLoadedButton()?.clearAnimation()
tryGetNewItemsLoadedButton() tryGetNewItemsLoadedButton()

View file

@ -56,7 +56,7 @@ class FeedViewModel(
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.map { (event, showPlayedItems, notLoadedCount, oldestUpdate) -> .map { (event, showPlayedItems, notLoadedCount, oldestUpdate) ->
var streamItems = if (event is SuccessResultEvent || event is IdleEvent) val streamItems = if (event is SuccessResultEvent || event is IdleEvent)
feedDatabaseManager feedDatabaseManager
.getStreams(groupId, showPlayedItems) .getStreams(groupId, showPlayedItems)
.blockingGet(arrayListOf()) .blockingGet(arrayListOf())

View file

@ -14,6 +14,8 @@ import org.schabi.newpipe.databinding.ListStreamItemBinding
import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_STREAM import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_AUDIO_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM
import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.PicassoHelper import org.schabi.newpipe.util.PicassoHelper
@ -109,7 +111,7 @@ data class StreamItem(
} }
override fun isLongClickable() = when (stream.streamType) { override fun isLongClickable() = when (stream.streamType) {
AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM -> true AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM, POST_LIVE_STREAM, POST_LIVE_AUDIO_STREAM -> true
else -> false else -> false
} }

View file

@ -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)
}
}
}
}

View file

@ -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)
}
}
}

View file

@ -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)
)
}
}
}

View file

@ -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
}
}

View file

@ -31,41 +31,24 @@ import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.preference.PreferenceManager
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Notification import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.functions.Consumer
import io.reactivex.rxjava3.functions.Function import io.reactivex.rxjava3.functions.Function
import io.reactivex.rxjava3.processors.PublishProcessor
import io.reactivex.rxjava3.schedulers.Schedulers
import org.reactivestreams.Subscriber
import org.reactivestreams.Subscription
import org.schabi.newpipe.App import org.schabi.newpipe.App
import org.schabi.newpipe.MainActivity.DEBUG import org.schabi.newpipe.MainActivity.DEBUG
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.extractor.ListInfo import org.schabi.newpipe.extractor.ListInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.FeedDatabaseManager
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ProgressEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.SuccessResultEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.util.ExtractorHelper
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
class FeedLoadService : Service() { class FeedLoadService : Service() {
companion object { companion object {
private val TAG = FeedLoadService::class.java.simpleName private val TAG = FeedLoadService::class.java.simpleName
private const val NOTIFICATION_ID = 7293450 const val NOTIFICATION_ID = 7293450
private const val ACTION_CANCEL = App.PACKAGE_NAME + ".local.feed.service.FeedLoadService.CANCEL" private const val ACTION_CANCEL = App.PACKAGE_NAME + ".local.feed.service.FeedLoadService.CANCEL"
/** /**
@ -73,27 +56,13 @@ class FeedLoadService : Service() {
*/ */
private const val NOTIFICATION_SAMPLING_PERIOD = 1500 private const val NOTIFICATION_SAMPLING_PERIOD = 1500
/**
* How many extractions will be running in parallel.
*/
private const val PARALLEL_EXTRACTIONS = 6
/**
* Number of items to buffer to mass-insert in the database.
*/
private const val BUFFER_COUNT_BEFORE_INSERT = 20
const val EXTRA_GROUP_ID: String = "FeedLoadService.EXTRA_GROUP_ID" const val EXTRA_GROUP_ID: String = "FeedLoadService.EXTRA_GROUP_ID"
} }
private var loadingSubscription: Subscription? = null private var loadingDisposable: Disposable? = null
private lateinit var subscriptionManager: SubscriptionManager private var notificationDisposable: Disposable? = null
private lateinit var feedDatabaseManager: FeedDatabaseManager private lateinit var feedLoadManager: FeedLoadManager
private lateinit var feedResultsHolder: ResultsHolder
private var disposables = CompositeDisposable()
private var notificationUpdater = PublishProcessor.create<String>()
// ///////////////////////////////////////////////////////////////////////// // /////////////////////////////////////////////////////////////////////////
// Lifecycle // Lifecycle
@ -101,8 +70,7 @@ class FeedLoadService : Service() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
subscriptionManager = SubscriptionManager(this) feedLoadManager = FeedLoadManager(this)
feedDatabaseManager = FeedDatabaseManager(this)
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -114,40 +82,45 @@ class FeedLoadService : Service() {
) )
} }
if (intent == null || loadingSubscription != null) { if (intent == null || loadingDisposable != null) {
return START_NOT_STICKY return START_NOT_STICKY
} }
setupNotification() setupNotification()
setupBroadcastReceiver() setupBroadcastReceiver()
val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
val groupId = intent.getLongExtra(EXTRA_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID) val groupId = intent.getLongExtra(EXTRA_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID)
val useFeedExtractor = defaultSharedPreferences loadingDisposable = feedLoadManager.startLoading(groupId)
.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false) .observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe {
val thresholdOutdatedSecondsString = defaultSharedPreferences startForeground(NOTIFICATION_ID, notificationBuilder.build())
.getString(getString(R.string.feed_update_threshold_key), getString(R.string.feed_update_threshold_default_value)) }
val thresholdOutdatedSeconds = thresholdOutdatedSecondsString!!.toInt() .subscribe { _, error ->
// There seems to be a bug in the kotlin plugin as it tells you when
startLoading(groupId, useFeedExtractor, thresholdOutdatedSeconds) // building that this can't be null:
// "Condition 'error != null' is always 'true'"
// However it can indeed be null
// The suppression may be removed in further versions
@Suppress("SENSELESS_COMPARISON")
if (error != null) {
Log.e(TAG, "Error while storing result", error)
handleError(error)
return@subscribe
}
stopService()
}
return START_NOT_STICKY return START_NOT_STICKY
} }
private fun disposeAll() { private fun disposeAll() {
unregisterReceiver(broadcastReceiver) unregisterReceiver(broadcastReceiver)
loadingDisposable?.dispose()
loadingSubscription?.cancel() notificationDisposable?.dispose()
loadingSubscription = null
disposables.dispose()
} }
private fun stopService() { private fun stopService() {
disposeAll() disposeAll()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
notificationManager.cancel(NOTIFICATION_ID)
stopSelf() stopSelf()
} }
@ -171,182 +144,6 @@ class FeedLoadService : Service() {
} }
} }
private fun startLoading(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, useFeedExtractor: Boolean, thresholdOutdatedSeconds: Int) {
feedResultsHolder = ResultsHolder()
val outdatedThreshold = OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong())
val subscriptions = when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
}
subscriptions
.take(1)
.doOnNext {
currentProgress.set(0)
maxProgress.set(it.size)
}
.filter { it.isNotEmpty() }
.observeOn(AndroidSchedulers.mainThread())
.doOnNext {
startForeground(NOTIFICATION_ID, notificationBuilder.build())
updateNotificationProgress(null)
broadcastProgress()
}
.observeOn(Schedulers.io())
.flatMap { Flowable.fromIterable(it) }
.takeWhile { !cancelSignal.get() }
.parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2)
.runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
.filter { !cancelSignal.get() }
.map { subscriptionEntity ->
var error: Throwable? = null
try {
val listInfo = if (useFeedExtractor) {
ExtractorHelper
.getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url)
.onErrorReturn {
error = it // store error, otherwise wrapped into RuntimeException
throw it
}
.blockingGet()
} else {
ExtractorHelper
.getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true)
.onErrorReturn {
error = it // store error, otherwise wrapped into RuntimeException
throw it
}
.blockingGet()
} as ListInfo<StreamInfoItem>
return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo))
} catch (e: Throwable) {
if (error == null) {
// do this to prevent blockingGet() from wrapping into RuntimeException
error = e
}
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
val wrapper = RequestException(subscriptionEntity.uid, request, error!!)
return@map Notification.createOnError<Pair<Long, ListInfo<StreamInfoItem>>>(wrapper)
}
}
.sequential()
.observeOn(AndroidSchedulers.mainThread())
.doOnNext(notificationsConsumer)
.observeOn(Schedulers.io())
.buffer(BUFFER_COUNT_BEFORE_INSERT)
.doOnNext(databaseConsumer)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(resultSubscriber)
}
private fun broadcastProgress() {
postEvent(ProgressEvent(currentProgress.get(), maxProgress.get()))
}
private val resultSubscriber
get() = object : Subscriber<List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>> {
override fun onSubscribe(s: Subscription) {
loadingSubscription = s
s.request(java.lang.Long.MAX_VALUE)
}
override fun onNext(notification: List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>) {
if (DEBUG) Log.v(TAG, "onNext() → $notification")
}
override fun onError(error: Throwable) {
handleError(error)
}
override fun onComplete() {
if (maxProgress.get() == 0) {
postEvent(FeedEventManager.Event.IdleEvent)
stopService()
return
}
currentProgress.set(-1)
maxProgress.set(-1)
notificationUpdater.onNext(getString(R.string.feed_processing_message))
postEvent(ProgressEvent(R.string.feed_processing_message))
disposables.add(
Single
.fromCallable {
feedResultsHolder.ready()
postEvent(ProgressEvent(R.string.feed_processing_message))
feedDatabaseManager.removeOrphansOrOlderStreams()
postEvent(SuccessResultEvent(feedResultsHolder.itemsErrors))
true
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { _, throwable ->
// There seems to be a bug in the kotlin plugin as it tells you when
// building that this can't be null:
// "Condition 'throwable != null' is always 'true'"
// However it can indeed be null
// The suppression may be removed in further versions
@Suppress("SENSELESS_COMPARISON")
if (throwable != null) {
Log.e(TAG, "Error while storing result", throwable)
handleError(throwable)
return@subscribe
}
stopService()
}
)
}
}
private val databaseConsumer: Consumer<List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>>
get() = Consumer {
feedDatabaseManager.database().runInTransaction {
for (notification in it) {
if (notification.isOnNext) {
val subscriptionId = notification.value!!.first
val info = notification.value!!.second
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
subscriptionManager.updateFromInfo(subscriptionId, info)
if (info.errors.isNotEmpty()) {
feedResultsHolder.addErrors(RequestException.wrapList(subscriptionId, info))
feedDatabaseManager.markAsOutdated(subscriptionId)
}
} else if (notification.isOnError) {
val error = notification.error!!
feedResultsHolder.addError(error)
if (error is RequestException) {
feedDatabaseManager.markAsOutdated(error.subscriptionId)
}
}
}
}
}
private val notificationsConsumer: Consumer<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>
get() = Consumer { onItemCompleted(it.value?.second?.name) }
private fun onItemCompleted(updateDescription: String?) {
currentProgress.incrementAndGet()
notificationUpdater.onNext(updateDescription ?: "")
broadcastProgress()
}
// ///////////////////////////////////////////////////////////////////////// // /////////////////////////////////////////////////////////////////////////
// Notification // Notification
// ///////////////////////////////////////////////////////////////////////// // /////////////////////////////////////////////////////////////////////////
@ -354,13 +151,12 @@ class FeedLoadService : Service() {
private lateinit var notificationManager: NotificationManagerCompat private lateinit var notificationManager: NotificationManagerCompat
private lateinit var notificationBuilder: NotificationCompat.Builder private lateinit var notificationBuilder: NotificationCompat.Builder
private var currentProgress = AtomicInteger(-1)
private var maxProgress = AtomicInteger(-1)
private fun createNotification(): NotificationCompat.Builder { private fun createNotification(): NotificationCompat.Builder {
val cancelActionIntent = PendingIntent.getBroadcast( val cancelActionIntent = PendingIntent.getBroadcast(
this, this,
NOTIFICATION_ID, Intent(ACTION_CANCEL), 0 NOTIFICATION_ID,
Intent(ACTION_CANCEL),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
) )
return NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) return NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
@ -376,33 +172,36 @@ class FeedLoadService : Service() {
notificationManager = NotificationManagerCompat.from(this) notificationManager = NotificationManagerCompat.from(this)
notificationBuilder = createNotification() notificationBuilder = createNotification()
val throttleAfterFirstEmission = Function { flow: Flowable<String> -> val throttleAfterFirstEmission = Function { flow: Flowable<FeedLoadState> ->
flow.take(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS)) flow.take(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS))
} }
disposables.add( notificationDisposable = feedLoadManager.notification
notificationUpdater .publish(throttleAfterFirstEmission)
.publish(throttleAfterFirstEmission) .observeOn(AndroidSchedulers.mainThread())
.observeOn(AndroidSchedulers.mainThread()) .doOnTerminate { notificationManager.cancel(NOTIFICATION_ID) }
.subscribe(this::updateNotificationProgress) .subscribe(this::updateNotificationProgress)
)
} }
private fun updateNotificationProgress(updateDescription: String?) { private fun updateNotificationProgress(state: FeedLoadState) {
notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1) notificationBuilder.setProgress(state.maxProgress, state.currentProgress, state.maxProgress == -1)
if (maxProgress.get() == -1) { if (state.maxProgress == -1) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null)
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription) if (state.updateDescription.isNotEmpty()) notificationBuilder.setContentText(state.updateDescription)
notificationBuilder.setContentText(updateDescription) notificationBuilder.setContentText(state.updateDescription)
} else { } else {
val progressText = this.currentProgress.toString() + "/" + maxProgress val progressText = state.currentProgress.toString() + "/" + state.maxProgress
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText("$updateDescription ($progressText)") if (state.updateDescription.isNotEmpty()) {
notificationBuilder.setContentText("${state.updateDescription} ($progressText)")
}
} else { } else {
notificationBuilder.setContentInfo(progressText) notificationBuilder.setContentInfo(progressText)
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription) if (state.updateDescription.isNotEmpty()) {
notificationBuilder.setContentText(state.updateDescription)
}
} }
} }
@ -414,13 +213,12 @@ class FeedLoadService : Service() {
// ///////////////////////////////////////////////////////////////////////// // /////////////////////////////////////////////////////////////////////////
private lateinit var broadcastReceiver: BroadcastReceiver private lateinit var broadcastReceiver: BroadcastReceiver
private val cancelSignal = AtomicBoolean()
private fun setupBroadcastReceiver() { private fun setupBroadcastReceiver() {
broadcastReceiver = object : BroadcastReceiver() { broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == ACTION_CANCEL) { if (intent?.action == ACTION_CANCEL) {
cancelSignal.set(true) feedLoadManager.cancel()
} }
} }
} }
@ -435,29 +233,4 @@ class FeedLoadService : Service() {
postEvent(ErrorResultEvent(error)) postEvent(ErrorResultEvent(error))
stopService() stopService()
} }
// /////////////////////////////////////////////////////////////////////////
// Results Holder
// /////////////////////////////////////////////////////////////////////////
class ResultsHolder {
/**
* List of errors that may have happen during loading.
*/
internal lateinit var itemsErrors: List<Throwable>
private val itemsErrorsHolder: MutableList<Throwable> = ArrayList()
fun addError(error: Throwable) {
itemsErrorsHolder.add(error)
}
fun addErrors(errors: List<Throwable>) {
itemsErrorsHolder.addAll(errors)
}
fun ready() {
itemsErrors = itemsErrorsHolder.toList()
}
}
} }

View file

@ -0,0 +1,7 @@
package org.schabi.newpipe.local.feed.service
data class FeedLoadState(
val updateDescription: String,
val maxProgress: Int,
val currentProgress: Int,
)

View file

@ -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)
}
}

View file

@ -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>
}

View file

@ -84,7 +84,7 @@ public abstract class HistoryEntryAdapter<E, VH extends RecyclerView.ViewHolder>
} }
@Override @Override
public void onViewRecycled(final VH holder) { public void onViewRecycled(@NonNull final VH holder) {
super.onViewRecycled(holder); super.onViewRecycled(holder);
holder.itemView.setOnClickListener(null); holder.itemView.setOnClickListener(null);
} }

View file

@ -128,13 +128,11 @@ public class HistoryRecordManager {
// Add a history entry // Add a history entry
final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId); final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId);
if (latestEntry != null) { if (latestEntry == null) {
streamHistoryTable.delete(latestEntry); // never actually viewed: add history entry but with 0 views
latestEntry.setAccessDate(currentTime); return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 0));
latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1);
return streamHistoryTable.insert(latestEntry);
} else { } else {
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime)); return 0L;
} }
})).subscribeOn(Schedulers.io()); })).subscribeOn(Schedulers.io());
} }
@ -155,7 +153,8 @@ public class HistoryRecordManager {
latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1); latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1);
return streamHistoryTable.insert(latestEntry); return streamHistoryTable.insert(latestEntry);
} else { } else {
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime)); // just viewed for the first time: set 1 view
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 1));
} }
})).subscribeOn(Schedulers.io()); })).subscribeOn(Schedulers.io());
} }

View file

@ -1,6 +1,5 @@
package org.schabi.newpipe.local.history; package org.schabi.newpipe.local.history;
import android.app.Activity;
import android.content.Context; import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
import android.os.Parcelable; import android.os.Parcelable;
@ -29,20 +28,16 @@ import org.schabi.newpipe.databinding.StatisticPlaylistControlBinding;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.InfoItemDialog;
import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.settings.HistorySettingsFragment; import org.schabi.newpipe.settings.HistorySettingsFragment;
import org.schabi.newpipe.util.external_communication.KoreUtils;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.util.StreamDialogEntry; import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
@ -154,7 +149,7 @@ public class StatisticsPlaylistFragment
@Override @Override
public void held(final LocalItem selectedItem) { public void held(final LocalItem selectedItem) {
if (selectedItem instanceof StreamStatisticsEntry) { if (selectedItem instanceof StreamStatisticsEntry) {
showStreamDialog((StreamStatisticsEntry) selectedItem); showInfoItemDialog((StreamStatisticsEntry) selectedItem);
} }
} }
}); });
@ -328,66 +323,30 @@ public class StatisticsPlaylistFragment
return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0)); return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0));
} }
private void showStreamDialog(final StreamStatisticsEntry item) { private void showInfoItemDialog(final StreamStatisticsEntry item) {
final Context context = getContext(); final Context context = getContext();
final Activity activity = getActivity();
if (context == null || context.getResources() == null || activity == null) {
return;
}
final StreamInfoItem infoItem = item.toStreamInfoItem(); final StreamInfoItem infoItem = item.toStreamInfoItem();
final ArrayList<StreamDialogEntry> entries = new ArrayList<>(); try {
final InfoItemDialog.Builder dialogBuilder =
new InfoItemDialog.Builder(getActivity(), context, this, infoItem);
if (PlayerHolder.getInstance().isPlayQueueReady()) { // set entries in the middle; the others are added automatically
entries.add(StreamDialogEntry.enqueue); dialogBuilder
.addEntry(StreamDialogDefaultEntry.DELETE)
if (PlayerHolder.getInstance().getQueueSize() > 1) { .setAction(
entries.add(StreamDialogEntry.enqueue_next); StreamDialogDefaultEntry.DELETE,
} (f, i) -> deleteEntry(
Math.max(itemListAdapter.getItemsList().indexOf(item), 0)))
.setAction(
StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND,
(f, i) -> NavigationHelper.playOnBackgroundPlayer(
context, getPlayQueueStartingAt(item), true))
.create()
.show();
} catch (final IllegalArgumentException e) {
InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem);
} }
if (infoItem.getStreamType() == StreamType.AUDIO_STREAM) {
entries.addAll(Arrays.asList(
StreamDialogEntry.start_here_on_background,
StreamDialogEntry.delete,
StreamDialogEntry.append_playlist,
StreamDialogEntry.share
));
} else {
entries.addAll(Arrays.asList(
StreamDialogEntry.start_here_on_background,
StreamDialogEntry.start_here_on_popup,
StreamDialogEntry.delete,
StreamDialogEntry.append_playlist,
StreamDialogEntry.share
));
}
entries.add(StreamDialogEntry.open_in_browser);
if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
entries.add(StreamDialogEntry.play_with_kodi);
}
// show "mark as watched" only when watch history is enabled
if (StreamDialogEntry.shouldAddMarkAsWatched(
item.getStreamEntity().getStreamType(),
context
)) {
entries.add(
StreamDialogEntry.mark_as_watched
);
}
entries.add(StreamDialogEntry.show_channel_details);
StreamDialogEntry.setEnabledEntries(entries);
StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItemDuplicate) ->
NavigationHelper
.playOnBackgroundPlayer(context, getPlayQueueStartingAt(item), true));
StreamDialogEntry.delete.setCustomAction((fragment, infoItemDuplicate) ->
deleteEntry(Math.max(itemListAdapter.getItemsList().indexOf(item), 0)));
new InfoItemDialog(activity, infoItem, StreamDialogEntry.getCommands(context),
(dialog, which) -> StreamDialogEntry.clickOn(which, this, infoItem)).show();
} }
private void deleteEntry(final int index) { private void deleteEntry(final int index) {

View file

@ -11,12 +11,12 @@ import androidx.core.content.ContextCompat;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.views.AnimatedProgressBar; import org.schabi.newpipe.views.AnimatedProgressBar;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
@ -59,7 +59,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
itemVideoTitleView.setText(item.getStreamEntity().getTitle()); itemVideoTitleView.setText(item.getStreamEntity().getTitle());
itemAdditionalDetailsView.setText(Localization itemAdditionalDetailsView.setText(Localization
.concatenateStrings(item.getStreamEntity().getUploader(), .concatenateStrings(item.getStreamEntity().getUploader(),
NewPipe.getNameOfService(item.getStreamEntity().getServiceId()))); ServiceHelper.getNameOfServiceById(item.getStreamEntity().getServiceId())));
if (item.getStreamEntity().getDuration() > 0) { if (item.getStreamEntity().getDuration() > 0) {
itemDurationView.setText(Localization itemDurationView.setText(Localization

View file

@ -11,12 +11,12 @@ import androidx.core.content.ContextCompat;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.stream.StreamStatisticsEntry; import org.schabi.newpipe.database.stream.StreamStatisticsEntry;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.views.AnimatedProgressBar; import org.schabi.newpipe.views.AnimatedProgressBar;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
@ -70,11 +70,12 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
private String getStreamInfoDetailLine(final StreamStatisticsEntry entry, private String getStreamInfoDetailLine(final StreamStatisticsEntry entry,
final DateTimeFormatter dateTimeFormatter) { final DateTimeFormatter dateTimeFormatter) {
final String watchCount = Localization return Localization.concatenateStrings(
.shortViewCount(itemBuilder.getContext(), entry.getWatchCount()); // watchCount
final String uploadDate = dateTimeFormatter.format(entry.getLatestAccessDate()); Localization.shortViewCount(itemBuilder.getContext(), entry.getWatchCount()),
final String serviceName = NewPipe.getNameOfService(entry.getStreamEntity().getServiceId()); dateTimeFormatter.format(entry.getLatestAccessDate()),
return Localization.concatenateStrings(watchCount, uploadDate, serviceName); // serviceName
ServiceHelper.getNameOfServiceById(entry.getStreamEntity().getServiceId()));
} }
@Override @Override

View file

@ -5,11 +5,11 @@ import android.view.ViewGroup;
import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.ServiceHelper;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
@ -39,9 +39,9 @@ public class RemotePlaylistItemHolder extends PlaylistItemHolder {
// Here is where the uploader name is set in the bookmarked playlists library // Here is where the uploader name is set in the bookmarked playlists library
if (!TextUtils.isEmpty(item.getUploader())) { if (!TextUtils.isEmpty(item.getUploader())) {
itemUploaderView.setText(Localization.concatenateStrings(item.getUploader(), itemUploaderView.setText(Localization.concatenateStrings(item.getUploader(),
NewPipe.getNameOfService(item.getServiceId()))); ServiceHelper.getNameOfServiceById(item.getServiceId())));
} else { } else {
itemUploaderView.setText(NewPipe.getNameOfService(item.getServiceId())); itemUploaderView.setText(ServiceHelper.getNameOfServiceById(item.getServiceId()));
} }
PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView); PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView);

View file

@ -1,6 +1,8 @@
package org.schabi.newpipe.local.playlist; package org.schabi.newpipe.local.playlist;
import android.app.Activity; import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
import android.content.Context; import android.content.Context;
import android.content.DialogInterface; import android.content.DialogInterface;
import android.os.Bundle; import android.os.Bundle;
@ -38,22 +40,18 @@ import org.schabi.newpipe.databinding.PlaylistControlBinding;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.InfoItemDialog;
import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.player.MainPlayer.PlayerType; import org.schabi.newpipe.player.MainPlayer.PlayerType;
import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.external_communication.KoreUtils;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.util.StreamDialogEntry; import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
@ -68,9 +66,6 @@ import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.schedulers.Schedulers;
import io.reactivex.rxjava3.subjects.PublishSubject; import io.reactivex.rxjava3.subjects.PublishSubject;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistStreamEntry>, Void> { public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistStreamEntry>, Void> {
// Save the list 10 seconds after the last change occurred // Save the list 10 seconds after the last change occurred
private static final long SAVE_DEBOUNCE_MILLIS = 10000; private static final long SAVE_DEBOUNCE_MILLIS = 10000;
@ -182,7 +177,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
@Override @Override
public void held(final LocalItem selectedItem) { public void held(final LocalItem selectedItem) {
if (selectedItem instanceof PlaylistStreamEntry) { if (selectedItem instanceof PlaylistStreamEntry) {
showStreamItemDialog((PlaylistStreamEntry) selectedItem); showInfoItemDialog((PlaylistStreamEntry) selectedItem);
} }
} }
@ -355,7 +350,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
new AlertDialog.Builder(requireContext()) new AlertDialog.Builder(requireContext())
.setMessage(R.string.remove_watched_popup_warning) .setMessage(R.string.remove_watched_popup_warning)
.setTitle(R.string.remove_watched_popup_title) .setTitle(R.string.remove_watched_popup_title)
.setPositiveButton(R.string.yes, .setPositiveButton(R.string.ok,
(DialogInterface d, int id) -> removeWatchedStreams(false)) (DialogInterface d, int id) -> removeWatchedStreams(false))
.setNeutralButton( .setNeutralButton(
R.string.remove_watched_popup_yes_and_partially_watched_videos, R.string.remove_watched_popup_yes_and_partially_watched_videos,
@ -424,9 +419,11 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
final PlaylistStreamEntry playlistItem = playlistIter.next(); final PlaylistStreamEntry playlistItem = playlistIter.next();
final int indexInHistory = Collections.binarySearch(historyStreamIds, final int indexInHistory = Collections.binarySearch(historyStreamIds,
playlistItem.getStreamId()); playlistItem.getStreamId());
final StreamStateEntity streamStateEntity = streamStatesIter.next();
final long duration = playlistItem.toStreamInfoItem().getDuration();
final boolean hasState = streamStatesIter.next() != null; if (indexInHistory < 0 || (streamStateEntity != null
if (indexInHistory < 0 || hasState) { && !streamStateEntity.isFinished(duration))) {
notWatchedItems.add(playlistItem); notWatchedItems.add(playlistItem);
} else if (!thumbnailVideoRemoved } else if (!thumbnailVideoRemoved
&& playlistManager.getPlaylistThumbnail(playlistId) && playlistManager.getPlaylistThumbnail(playlistId)
@ -743,70 +740,39 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0)); return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0));
} }
protected void showStreamItemDialog(final PlaylistStreamEntry item) { protected void showInfoItemDialog(final PlaylistStreamEntry item) {
final Context context = getContext();
final Activity activity = getActivity();
if (context == null || context.getResources() == null || activity == null) {
return;
}
final StreamInfoItem infoItem = item.toStreamInfoItem(); final StreamInfoItem infoItem = item.toStreamInfoItem();
final ArrayList<StreamDialogEntry> entries = new ArrayList<>(); try {
final Context context = getContext();
final InfoItemDialog.Builder dialogBuilder =
new InfoItemDialog.Builder(getActivity(), context, this, infoItem);
if (PlayerHolder.getInstance().isPlayQueueReady()) { // add entries in the middle
entries.add(StreamDialogEntry.enqueue); dialogBuilder.addAllEntries(
StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL,
if (PlayerHolder.getInstance().getQueueSize() > 1) { StreamDialogDefaultEntry.DELETE
entries.add(StreamDialogEntry.enqueue_next);
}
}
if (infoItem.getStreamType() == StreamType.AUDIO_STREAM) {
entries.addAll(Arrays.asList(
StreamDialogEntry.start_here_on_background,
StreamDialogEntry.set_as_playlist_thumbnail,
StreamDialogEntry.delete,
StreamDialogEntry.append_playlist,
StreamDialogEntry.share
));
} else {
entries.addAll(Arrays.asList(
StreamDialogEntry.start_here_on_background,
StreamDialogEntry.start_here_on_popup,
StreamDialogEntry.set_as_playlist_thumbnail,
StreamDialogEntry.delete,
StreamDialogEntry.append_playlist,
StreamDialogEntry.share
));
}
entries.add(StreamDialogEntry.open_in_browser);
if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
entries.add(StreamDialogEntry.play_with_kodi);
}
// show "mark as watched" only when watch history is enabled
if (StreamDialogEntry.shouldAddMarkAsWatched(
item.getStreamEntity().getStreamType(),
context
)) {
entries.add(
StreamDialogEntry.mark_as_watched
); );
// set custom actions
// all entries modified below have already been added within the builder
dialogBuilder
.setAction(
StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND,
(f, i) -> NavigationHelper.playOnBackgroundPlayer(
context, getPlayQueueStartingAt(item), true))
.setAction(
StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL,
(f, i) ->
changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl()))
.setAction(
StreamDialogDefaultEntry.DELETE,
(f, i) -> deleteItem(item))
.create()
.show();
} catch (final IllegalArgumentException e) {
InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem);
} }
entries.add(StreamDialogEntry.show_channel_details);
StreamDialogEntry.setEnabledEntries(entries);
StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItemDuplicate) ->
NavigationHelper.playOnBackgroundPlayer(context,
getPlayQueueStartingAt(item), true));
StreamDialogEntry.set_as_playlist_thumbnail.setCustomAction(
(fragment, infoItemDuplicate) ->
changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl()));
StreamDialogEntry.delete.setCustomAction((fragment, infoItemDuplicate) ->
deleteItem(item));
new InfoItemDialog(activity, infoItem, StreamDialogEntry.getCommands(context),
(dialog, which) -> StreamDialogEntry.clickOn(which, this, infoItem)).show();
} }
private void setInitialData(final long pid, final String title) { private void setInitialData(final long pid, final String title) {

View file

@ -61,7 +61,7 @@ public class ImportConfirmationDialog extends DialogFragment {
} }
@Override @Override
public void onSaveInstanceState(final Bundle outState) { public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState); super.onSaveInstanceState(outState);
Icepick.saveInstanceState(this, outState); Icepick.saveInstanceState(this, outState);
} }

View file

@ -1,24 +1,24 @@
package org.schabi.newpipe.local.subscription package org.schabi.newpipe.local.subscription
import android.app.Activity import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
import android.view.MenuInflater import android.view.MenuInflater
import android.view.MenuItem
import android.view.SubMenu
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import com.xwray.groupie.Group import com.xwray.groupie.Group
import com.xwray.groupie.GroupAdapter import com.xwray.groupie.GroupAdapter
@ -34,6 +34,7 @@ import org.schabi.newpipe.databinding.FeedItemCarouselBinding
import org.schabi.newpipe.databinding.FragmentSubscriptionBinding import org.schabi.newpipe.databinding.FragmentSubscriptionBinding
import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.UserAction import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.channel.ChannelInfoItem import org.schabi.newpipe.extractor.channel.ChannelInfoItem
import org.schabi.newpipe.fragments.BaseStateFragment import org.schabi.newpipe.fragments.BaseStateFragment
import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.ktx.animate
@ -45,13 +46,10 @@ import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem
import org.schabi.newpipe.local.subscription.item.FeedGroupAddItem import org.schabi.newpipe.local.subscription.item.FeedGroupAddItem
import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem
import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem
import org.schabi.newpipe.local.subscription.item.FeedImportExportItem
import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem
import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem.Companion.PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem.Companion.PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE_ACTION
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.IMPORT_COMPLETE_ACTION
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE
@ -59,6 +57,7 @@ import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard
import org.schabi.newpipe.streams.io.StoredFileHelper import org.schabi.newpipe.streams.io.StoredFileHelper
import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.OnClickGesture import org.schabi.newpipe.util.OnClickGesture
import org.schabi.newpipe.util.ServiceHelper
import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountChannels import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountChannels
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
import org.schabi.newpipe.util.external_communication.ShareUtils import org.schabi.newpipe.util.external_communication.ShareUtils
@ -74,12 +73,9 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
private lateinit var subscriptionManager: SubscriptionManager private lateinit var subscriptionManager: SubscriptionManager
private val disposables: CompositeDisposable = CompositeDisposable() private val disposables: CompositeDisposable = CompositeDisposable()
private var subscriptionBroadcastReceiver: BroadcastReceiver? = null
private val groupAdapter = GroupAdapter<GroupieViewHolder<FeedItemCarouselBinding>>() private val groupAdapter = GroupAdapter<GroupieViewHolder<FeedItemCarouselBinding>>()
private val feedGroupsSection = Section() private val feedGroupsSection = Section()
private var feedGroupsCarousel: FeedGroupCarouselItem? = null private var feedGroupsCarousel: FeedGroupCarouselItem? = null
private lateinit var importExportItem: FeedImportExportItem
private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem
private val subscriptionsSection = Section() private val subscriptionsSection = Section()
@ -91,12 +87,10 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
@State @State
@JvmField @JvmField
var itemsListState: Parcelable? = null var itemsListState: Parcelable? = null
@State @State
@JvmField @JvmField
var feedGroupsListState: Parcelable? = null var feedGroupsListState: Parcelable? = null
@State
@JvmField
var importExportItemExpandedState: Boolean? = null
init { init {
setHasOptionsMenu(true) setHasOptionsMenu(true)
@ -120,20 +114,10 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
return inflater.inflate(R.layout.fragment_subscription, container, false) return inflater.inflate(R.layout.fragment_subscription, container, false)
} }
override fun onResume() {
super.onResume()
setupBroadcastReceiver()
}
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
itemsListState = binding.itemsList.layoutManager?.onSaveInstanceState() itemsListState = binding.itemsList.layoutManager?.onSaveInstanceState()
feedGroupsListState = feedGroupsCarousel?.onSaveInstanceState() feedGroupsListState = feedGroupsCarousel?.onSaveInstanceState()
importExportItemExpandedState = importExportItem.isExpanded
if (subscriptionBroadcastReceiver != null && activity != null) {
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!)
}
} }
override fun onDestroy() { override fun onDestroy() {
@ -150,28 +134,61 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
activity.supportActionBar?.setDisplayShowTitleEnabled(true) activity.supportActionBar?.setDisplayShowTitleEnabled(true)
activity.supportActionBar?.setTitle(R.string.tab_subscriptions) activity.supportActionBar?.setTitle(R.string.tab_subscriptions)
buildImportExportMenu(menu)
} }
private fun setupBroadcastReceiver() { private fun buildImportExportMenu(menu: Menu) {
if (activity == null) return // -- Import --
val importSubMenu = menu.addSubMenu(R.string.import_from)
if (subscriptionBroadcastReceiver != null) { addMenuItemToSubmenu(importSubMenu, R.string.previous_export) { onImportPreviousSelected() }
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!) .setIcon(R.drawable.ic_backup)
}
val filters = IntentFilter() for (service in ServiceList.all()) {
filters.addAction(EXPORT_COMPLETE_ACTION) val subscriptionExtractor = service.subscriptionExtractor ?: continue
filters.addAction(IMPORT_COMPLETE_ACTION)
subscriptionBroadcastReceiver = object : BroadcastReceiver() { val supportedSources = subscriptionExtractor.supportedSources
override fun onReceive(context: Context, intent: Intent) { if (supportedSources.isEmpty()) continue
_binding?.itemsList?.post {
importExportItem.isExpanded = false addMenuItemToSubmenu(importSubMenu, service.serviceInfo.name) {
importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS) 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) { private fun onImportFromServiceSelected(serviceId: Int) {
@ -263,13 +280,14 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
subscriptionsSection.setPlaceholder(EmptyPlaceholderItem()) subscriptionsSection.setPlaceholder(EmptyPlaceholderItem())
subscriptionsSection.setHideWhenEmpty(true) subscriptionsSection.setHideWhenEmpty(true)
importExportItem = FeedImportExportItem( groupAdapter.add(
{ onImportPreviousSelected() }, Section(
{ onImportFromServiceSelected(it) }, HeaderWithMenuItem(
{ onExportSelected() }, getString(R.string.tab_subscriptions)
importExportItemExpandedState ?: false ),
listOf(subscriptionsSection)
)
) )
groupAdapter.add(Section(importExportItem, listOf(subscriptionsSection)))
} }
override fun initViews(rootView: View, savedInstanceState: Bundle?) { override fun initViews(rootView: View, savedInstanceState: Bundle?) {
@ -371,13 +389,6 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
subscriptionsSection.update(result.subscriptions) subscriptionsSection.update(result.subscriptions)
subscriptionsSection.setHideWhenEmpty(false) subscriptionsSection.setHideWhenEmpty(false)
if (result.subscriptions.isEmpty() && importExportItemExpandedState == null) {
binding.itemsList.post {
importExportItem.isExpanded = true
importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS)
}
}
if (itemsListState != null) { if (itemsListState != null) {
binding.itemsList.layoutManager?.onRestoreInstanceState(itemsListState) binding.itemsList.layoutManager?.onRestoreInstanceState(itemsListState)
itemsListState = null itemsListState = null

View file

@ -7,6 +7,8 @@ import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionDAO import org.schabi.newpipe.database.subscription.SubscriptionDAO
import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.extractor.ListInfo import org.schabi.newpipe.extractor.ListInfo
@ -14,6 +16,7 @@ import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.feed.FeedInfo import org.schabi.newpipe.extractor.feed.FeedInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.FeedDatabaseManager import org.schabi.newpipe.local.feed.FeedDatabaseManager
import org.schabi.newpipe.util.ExtractorHelper
class SubscriptionManager(context: Context) { class SubscriptionManager(context: Context) {
private val database = NewPipeDatabase.getInstance(context) private val database = NewPipeDatabase.getInstance(context)
@ -66,13 +69,33 @@ class SubscriptionManager(context: Context) {
} }
} }
fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable {
return subscriptionTable().getSubscription(serviceId, url)
.flatMapCompletable { entity: SubscriptionEntity ->
Completable.fromAction {
entity.notificationMode = mode
subscriptionTable().update(entity)
}.apply {
if (mode != NotificationMode.DISABLED) {
// notifications have just been enabled, mark all streams as "old"
andThen(rememberAllStreams(entity))
}
}
}
}
fun updateFromInfo(subscriptionId: Long, info: ListInfo<StreamInfoItem>) { fun updateFromInfo(subscriptionId: Long, info: ListInfo<StreamInfoItem>) {
val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId) val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId)
if (info is FeedInfo) { if (info is FeedInfo) {
subscriptionEntity.name = info.name subscriptionEntity.name = info.name
} else if (info is ChannelInfo) { } else if (info is ChannelInfo) {
subscriptionEntity.setData(info.name, info.avatarUrl, info.description, info.subscriberCount) subscriptionEntity.setData(
info.name,
info.avatarUrl,
info.description,
info.subscriberCount
)
} }
subscriptionTable.update(subscriptionEntity) subscriptionTable.update(subscriptionEntity)
@ -94,4 +117,19 @@ class SubscriptionManager(context: Context) {
fun deleteSubscription(subscriptionEntity: SubscriptionEntity) { fun deleteSubscription(subscriptionEntity: SubscriptionEntity) {
subscriptionTable.delete(subscriptionEntity) subscriptionTable.delete(subscriptionEntity)
} }
/**
* Fetches the list of videos for the provided channel and saves them in the database, so that
* they will be considered as "old"/"already seen" streams and the user will never be notified
* about any one of them.
*/
private fun rememberAllStreams(subscription: SubscriptionEntity): Completable {
return ExtractorHelper.getChannelInfo(subscription.serviceId, subscription.url, false)
.map { channel -> channel.relatedItems.map { stream -> StreamEntity(stream) } }
.flatMapCompletable { entities ->
Completable.fromAction {
database.streamDAO().upsertAll(entities)
}
}.onErrorComplete()
}
} }

View file

@ -1,5 +1,11 @@
package org.schabi.newpipe.local.subscription; package org.schabi.newpipe.local.subscription;
import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE;
import android.app.Activity; import android.app.Activity;
import android.content.Intent; import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
@ -40,12 +46,6 @@ import java.util.List;
import icepick.State; import icepick.State;
import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE;
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE;
public class SubscriptionsImportFragment extends BaseFragment { public class SubscriptionsImportFragment extends BaseFragment {
@State @State
int currentServiceId = Constants.NO_SERVICE_ID; int currentServiceId = Constants.NO_SERVICE_ID;
@ -89,7 +89,7 @@ public class SubscriptionsImportFragment extends BaseFragment {
if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) { if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) {
ErrorUtil.showSnackbar(activity, ErrorUtil.showSnackbar(activity,
new ErrorInfo(new String[]{}, UserAction.SUBSCRIPTION_IMPORT_EXPORT, new ErrorInfo(new String[]{}, UserAction.SUBSCRIPTION_IMPORT_EXPORT,
NewPipe.getNameOfService(currentServiceId), ServiceHelper.getNameOfServiceById(currentServiceId),
"Service does not support importing subscriptions", "Service does not support importing subscriptions",
R.string.general_error)); R.string.general_error));
activity.finish(); activity.finish();

View file

@ -1,7 +1,6 @@
package org.schabi.newpipe.local.subscription.dialog package org.schabi.newpipe.local.subscription.dialog
import android.app.Dialog import android.app.Dialog
import android.content.res.ColorStateList
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.view.LayoutInflater import android.view.LayoutInflater
@ -9,7 +8,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Toast import android.widget.Toast
import androidx.core.content.ContextCompat import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.view.isGone import androidx.core.view.isGone
@ -127,7 +126,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP) { if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP) {
// KitKat doesn't apply container's theme to <include> content // KitKat doesn't apply container's theme to <include> content
val contrastColor = ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.contrastColor)) val contrastColor = AppCompatResources.getColorStateList(requireContext(), R.color.contrastColor)
searchLayoutBinding.toolbarSearchEditText.setTextColor(contrastColor) searchLayoutBinding.toolbarSearchEditText.setTextColor(contrastColor)
searchLayoutBinding.toolbarSearchEditText.setHintTextColor(contrastColor.withAlpha(128)) searchLayoutBinding.toolbarSearchEditText.setHintTextColor(contrastColor.withAlpha(128))
ImageViewCompat.setImageTintList(searchLayoutBinding.toolbarSearchClearIcon, contrastColor) ImageViewCompat.setImageTintList(searchLayoutBinding.toolbarSearchClearIcon, contrastColor)

View file

@ -8,7 +8,6 @@ import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.functions.BiFunction
import io.reactivex.rxjava3.processors.BehaviorProcessor import io.reactivex.rxjava3.processors.BehaviorProcessor
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.feed.model.FeedGroupEntity
@ -33,9 +32,8 @@ class FeedGroupDialogViewModel(
private var subscriptionsFlowable = Flowable private var subscriptionsFlowable = Flowable
.combineLatest( .combineLatest(
filterSubscriptions.startWithItem(initialQuery), filterSubscriptions.startWithItem(initialQuery),
toggleShowOnlyUngrouped.startWithItem(initialShowOnlyUngrouped), toggleShowOnlyUngrouped.startWithItem(initialShowOnlyUngrouped)
BiFunction { t1: String, t2: Boolean -> Filter(t1, t2) } ) { t1: String, t2: Boolean -> Filter(t1, t2) }
)
.distinctUntilChanged() .distinctUntilChanged()
.switchMap { (query, showOnlyUngrouped) -> .switchMap { (query, showOnlyUngrouped) ->
subscriptionManager.getSubscriptions(groupId, query, showOnlyUngrouped) subscriptionManager.getSubscriptions(groupId, query, showOnlyUngrouped)
@ -56,9 +54,8 @@ class FeedGroupDialogViewModel(
private var subscriptionsDisposable = Flowable private var subscriptionsDisposable = Flowable
.combineLatest( .combineLatest(
subscriptionsFlowable, feedDatabaseManager.subscriptionIdsForGroup(groupId), subscriptionsFlowable, feedDatabaseManager.subscriptionIdsForGroup(groupId)
BiFunction { t1: List<PickerSubscriptionItem>, t2: List<Long> -> t1 to t2.toSet() } ) { t1: List<PickerSubscriptionItem>, t2: List<Long> -> t1 to t2.toSet() }
)
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.subscribe(mutableSubscriptionsLiveData::postValue) .subscribe(mutableSubscriptionsLiveData::postValue)

View file

@ -57,15 +57,12 @@ class FeedGroupReorderDialog : DialogFragment() {
viewModel = ViewModelProvider(this).get(FeedGroupReorderDialogViewModel::class.java) viewModel = ViewModelProvider(this).get(FeedGroupReorderDialogViewModel::class.java)
viewModel.groupsLiveData.observe(viewLifecycleOwner, Observer(::handleGroups)) viewModel.groupsLiveData.observe(viewLifecycleOwner, Observer(::handleGroups))
viewModel.dialogEventLiveData.observe( viewModel.dialogEventLiveData.observe(viewLifecycleOwner) {
viewLifecycleOwner, when (it) {
Observer { ProcessingEvent -> disableInput()
when (it) { SuccessEvent -> dismiss()
ProcessingEvent -> disableInput()
SuccessEvent -> dismiss()
}
} }
) }
binding.feedGroupsList.layoutManager = LinearLayoutManager(requireContext()) binding.feedGroupsList.layoutManager = LinearLayoutManager(requireContext())
binding.feedGroupsList.adapter = groupAdapter binding.feedGroupsList.adapter = groupAdapter

View file

@ -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() }
}
}

View file

@ -25,7 +25,6 @@ import com.grack.nanojson.JsonAppendableWriter;
import com.grack.nanojson.JsonArray; import com.grack.nanojson.JsonArray;
import com.grack.nanojson.JsonObject; import com.grack.nanojson.JsonObject;
import com.grack.nanojson.JsonParser; import com.grack.nanojson.JsonParser;
import com.grack.nanojson.JsonSink;
import com.grack.nanojson.JsonWriter; import com.grack.nanojson.JsonWriter;
import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.BuildConfig;
@ -125,10 +124,11 @@ public final class ImportExportJsonHelper {
/** /**
* @see #writeTo(List, OutputStream, ImportExportEventListener) * @see #writeTo(List, OutputStream, ImportExportEventListener)
* @param items the list of subscriptions items * @param items the list of subscriptions items
* @param writer the output {@link JsonSink} * @param writer the output {@link JsonAppendableWriter}
* @param eventListener listener for the events generated * @param eventListener listener for the events generated
*/ */
public static void writeTo(final List<SubscriptionItem> items, final JsonSink writer, public static void writeTo(final List<SubscriptionItem> items,
final JsonAppendableWriter writer,
@Nullable final ImportExportEventListener eventListener) { @Nullable final ImportExportEventListener eventListener) {
if (eventListener != null) { if (eventListener != null) {
eventListener.onSizeReceived(items.size()); eventListener.onSizeReceived(items.size());

View file

@ -11,7 +11,6 @@ import androidx.preference.PreferenceManager;
import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player.EventListener;
import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.SeekParameters;
import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource;
@ -36,7 +35,7 @@ import static org.schabi.newpipe.player.Player.STATE_PAUSED;
import static org.schabi.newpipe.player.Player.STATE_PAUSED_SEEK; import static org.schabi.newpipe.player.Player.STATE_PAUSED_SEEK;
import static org.schabi.newpipe.player.Player.STATE_PLAYING; import static org.schabi.newpipe.player.Player.STATE_PLAYING;
public class LocalPlayer implements EventListener { public class LocalPlayer implements com.google.android.exoplayer2.Player.Listener {
private static final String TAG = "LocalPlayer"; private static final String TAG = "LocalPlayer";
private static final int PROGRESS_LOOP_INTERVAL_MILLIS = 500; private static final int PROGRESS_LOOP_INTERVAL_MILLIS = 500;
@ -325,6 +324,9 @@ public class LocalPlayer implements EventListener {
case "preview": case "preview":
toastText = context toastText = context
.getString(R.string.sponsor_block_skip_preview_toast); .getString(R.string.sponsor_block_skip_preview_toast);
case "filler":
toastText = context
.getString(R.string.sponsor_block_skip_filler_toast);
break; break;
} }

View file

@ -1,5 +1,9 @@
package org.schabi.newpipe.player; package org.schabi.newpipe.player;
import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu;
import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.content.ComponentName; import android.content.ComponentName;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
@ -25,11 +29,9 @@ import androidx.recyclerview.widget.RecyclerView;
import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.PlaybackParameters;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.databinding.ActivityPlayerQueueControlBinding; import org.schabi.newpipe.databinding.ActivityPlayerQueueControlBinding;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.event.PlayerEventListener; import org.schabi.newpipe.player.event.PlayerEventListener;
import org.schabi.newpipe.player.helper.PlaybackParameterDialog; import org.schabi.newpipe.player.helper.PlaybackParameterDialog;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
@ -44,13 +46,6 @@ import org.schabi.newpipe.util.PermissionHelper;
import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.ThemeHelper;
import java.util.List;
import java.util.stream.Collectors;
import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu;
import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import static org.schabi.newpipe.util.SponsorBlockUtils.markSegments; import static org.schabi.newpipe.util.SponsorBlockUtils.markSegments;
public final class PlayQueueActivity extends AppCompatActivity public final class PlayQueueActivity extends AppCompatActivity
@ -107,7 +102,10 @@ public final class PlayQueueActivity extends AppCompatActivity
getMenuInflater().inflate(R.menu.menu_play_queue, m); getMenuInflater().inflate(R.menu.menu_play_queue, m);
getMenuInflater().inflate(R.menu.menu_play_queue_bg, m); getMenuInflater().inflate(R.menu.menu_play_queue_bg, m);
onMaybeMuteChanged(); onMaybeMuteChanged();
onPlaybackParameterChanged(player.getPlaybackParameters()); // to avoid null reference
if (player != null) {
onPlaybackParameterChanged(player.getPlaybackParameters());
}
return true; return true;
} }
@ -133,7 +131,7 @@ public final class PlayQueueActivity extends AppCompatActivity
NavigationHelper.openSettings(this); NavigationHelper.openSettings(this);
return true; return true;
case R.id.action_append_playlist: case R.id.action_append_playlist:
appendAllToPlaylist(); player.onAddToPlaylistClicked(getSupportFragmentManager());
return true; return true;
case R.id.action_playback_speed: case R.id.action_playback_speed:
openPlaybackParameterDialog(); openPlaybackParameterDialog();
@ -453,24 +451,6 @@ public final class PlayQueueActivity extends AppCompatActivity
seeking = false; seeking = false;
} }
////////////////////////////////////////////////////////////////////////////
// Playlist append
////////////////////////////////////////////////////////////////////////////
private void appendAllToPlaylist() {
if (player != null && player.getPlayQueue() != null) {
openPlaylistAppendDialog(player.getPlayQueue().getStreams());
}
}
private void openPlaylistAppendDialog(final List<PlayQueueItem> playQueueItems) {
PlaylistDialog.createCorrespondingDialog(
getApplicationContext(),
playQueueItems.stream().map(StreamEntity::new).collect(Collectors.toList()),
dialog -> dialog.show(getSupportFragmentManager(), TAG)
);
}
//////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////
// Binding Service Listener // Binding Service Listener
//////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////
@ -634,7 +614,6 @@ public final class PlayQueueActivity extends AppCompatActivity
//2) Icon change accordingly to current App Theme //2) Icon change accordingly to current App Theme
// using rootView.getContext() because getApplicationContext() didn't work // using rootView.getContext() because getApplicationContext() didn't work
final Context context = queueControlBinding.getRoot().getContext();
item.setIcon(player.isMuted() ? R.drawable.ic_volume_off : R.drawable.ic_volume_up); item.setIcon(player.isMuted() ? R.drawable.ic_volume_off : R.drawable.ic_volume_up);
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -126,6 +126,14 @@ public class PlayerGestureListener
} }
private void onScrollMainVolume(final float distanceX, final float distanceY) { private void onScrollMainVolume(final float distanceX, final float distanceY) {
// If we just started sliding, change the progress bar to match the system volume
if (player.getVolumeRelativeLayout().getVisibility() != View.VISIBLE) {
final float volumePercent = player
.getAudioReactor().getVolume() / (float) maxVolume;
player.getVolumeProgressBar().setProgress(
(int) (volumePercent * player.getMaxGestureLength()));
}
player.getVolumeProgressBar().incrementProgressBy((int) distanceY); player.getVolumeProgressBar().incrementProgressBy((int) distanceY);
final float currentProgressPercent = (float) player final float currentProgressPercent = (float) player
.getVolumeProgressBar().getProgress() / player.getMaxGestureLength(); .getVolumeProgressBar().getProgress() / player.getMaxGestureLength();

View file

@ -1,6 +1,6 @@
package org.schabi.newpipe.player.event; package org.schabi.newpipe.player.event;
import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.PlaybackException;
public interface PlayerServiceEventListener extends PlayerEventListener { public interface PlayerServiceEventListener extends PlayerEventListener {
void onFullscreenStateChanged(boolean fullscreen); void onFullscreenStateChanged(boolean fullscreen);
@ -9,7 +9,7 @@ public interface PlayerServiceEventListener extends PlayerEventListener {
void onMoreOptionsLongClicked(); void onMoreOptionsLongClicked();
void onPlayerError(ExoPlaybackException error); void onPlayerError(PlaybackException error, boolean isCatchableException);
void hideSystemUiIfNeeded(); void hideSystemUiIfNeeded();
} }

View file

@ -14,7 +14,7 @@ import androidx.core.content.ContextCompat;
import androidx.media.AudioFocusRequestCompat; import androidx.media.AudioFocusRequestCompat;
import androidx.media.AudioManagerCompat; import androidx.media.AudioManagerCompat;
import com.google.android.exoplayer2.SimpleExoPlayer; import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.analytics.AnalyticsListener; import com.google.android.exoplayer2.analytics.AnalyticsListener;
public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, AnalyticsListener { public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, AnalyticsListener {
@ -27,14 +27,14 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, An
private static final int FOCUS_GAIN_TYPE = AudioManagerCompat.AUDIOFOCUS_GAIN; private static final int FOCUS_GAIN_TYPE = AudioManagerCompat.AUDIOFOCUS_GAIN;
private static final int STREAM_TYPE = AudioManager.STREAM_MUSIC; private static final int STREAM_TYPE = AudioManager.STREAM_MUSIC;
private final SimpleExoPlayer player; private final ExoPlayer player;
private final Context context; private final Context context;
private final AudioManager audioManager; private final AudioManager audioManager;
private final AudioFocusRequestCompat request; private final AudioFocusRequestCompat request;
public AudioReactor(@NonNull final Context context, public AudioReactor(@NonNull final Context context,
@NonNull final SimpleExoPlayer player) { @NonNull final ExoPlayer player) {
this.player = player; this.player = player;
this.context = context; this.context = context;
this.audioManager = ContextCompat.getSystemService(context, AudioManager.class); this.audioManager = ContextCompat.getSystemService(context, AudioManager.class);
@ -149,7 +149,8 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, An
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override @Override
public void onAudioSessionIdChanged(final EventTime eventTime, final int audioSessionId) { public void onAudioSessionIdChanged(@NonNull final EventTime eventTime,
final int audioSessionId) {
notifyAudioSessionUpdate(true, audioSessionId); notifyAudioSessionUpdate(true, audioSessionId);
} }
private void notifyAudioSessionUpdate(final boolean active, final int audioSessionId) { private void notifyAudioSessionUpdate(final boolean active, final int audioSessionId) {

View file

@ -1,93 +1,46 @@
package org.schabi.newpipe.player.helper; package org.schabi.newpipe.player.helper;
import android.content.Context; import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import com.google.android.exoplayer2.database.ExoDatabaseProvider;
import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DefaultDataSource; import com.google.android.exoplayer2.upstream.DefaultDataSource;
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
import com.google.android.exoplayer2.upstream.FileDataSource; import com.google.android.exoplayer2.upstream.FileDataSource;
import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.upstream.TransferListener;
import com.google.android.exoplayer2.upstream.cache.CacheDataSink; import com.google.android.exoplayer2.upstream.cache.CacheDataSink;
import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.CacheDataSource;
import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor;
import com.google.android.exoplayer2.upstream.cache.SimpleCache; import com.google.android.exoplayer2.upstream.cache.SimpleCache;
import java.io.File; final class CacheFactory implements DataSource.Factory {
private static final int CACHE_FLAGS = CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR;
/* package-private */ class CacheFactory implements DataSource.Factory { private final Context context;
private static final String TAG = "CacheFactory"; private final TransferListener transferListener;
private final DataSource.Factory upstreamDataSourceFactory;
private final SimpleCache cache;
private static final String CACHE_FOLDER_NAME = "exoplayer"; CacheFactory(final Context context,
private static final int CACHE_FLAGS = CacheDataSource.FLAG_BLOCK_ON_CACHE final TransferListener transferListener,
| CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; final SimpleCache cache,
final DataSource.Factory upstreamDataSourceFactory) {
private final DefaultDataSourceFactory dataSourceFactory; this.context = context;
private final File cacheDir; this.transferListener = transferListener;
private final long maxFileSize; this.cache = cache;
this.upstreamDataSourceFactory = upstreamDataSourceFactory;
// Creating cache on every instance may cause problems with multiple players when
// sources are not ExtractorMediaSource
// see: https://stackoverflow.com/questions/28700391/using-cache-in-exoplayer
// todo: make this a singleton?
private static SimpleCache cache;
CacheFactory(@NonNull final Context context,
@NonNull final String userAgent,
@NonNull final TransferListener transferListener) {
this(context, userAgent, transferListener, PlayerHelper.getPreferredCacheSize(),
PlayerHelper.getPreferredFileSize());
}
private CacheFactory(@NonNull final Context context,
@NonNull final String userAgent,
@NonNull final TransferListener transferListener,
final long maxCacheSize,
final long maxFileSize) {
this.maxFileSize = maxFileSize;
dataSourceFactory = new DefaultDataSourceFactory(context, userAgent, transferListener);
cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME);
if (!cacheDir.exists()) {
//noinspection ResultOfMethodCallIgnored
cacheDir.mkdir();
}
if (cache == null) {
final LeastRecentlyUsedCacheEvictor evictor
= new LeastRecentlyUsedCacheEvictor(maxCacheSize);
cache = new SimpleCache(cacheDir, evictor, new ExoDatabaseProvider(context));
}
} }
@NonNull
@Override @Override
public DataSource createDataSource() { public DataSource createDataSource() {
Log.d(TAG, "initExoPlayerCache: cacheDir = " + cacheDir.getAbsolutePath()); final DefaultDataSource dataSource = new DefaultDataSource.Factory(context,
upstreamDataSourceFactory)
.setTransferListener(transferListener)
.createDataSource();
final DefaultDataSource dataSource = dataSourceFactory.createDataSource();
final FileDataSource fileSource = new FileDataSource(); final FileDataSource fileSource = new FileDataSource();
final CacheDataSink dataSink = new CacheDataSink(cache, maxFileSize); final CacheDataSink dataSink
= new CacheDataSink(cache, PlayerHelper.getPreferredFileSize());
return new CacheDataSource(cache, dataSource, fileSource, dataSink, CACHE_FLAGS, null); return new CacheDataSource(cache, dataSource, fileSource, dataSink, CACHE_FLAGS, null);
} }
public void tryDeleteCacheFiles() {
if (!cacheDir.exists() || !cacheDir.isDirectory()) {
return;
}
try {
for (final File file : cacheDir.listFiles()) {
final String filePath = file.getAbsolutePath();
final boolean deleteSuccessful = file.delete();
Log.d(TAG, "tryDeleteCacheFiles: " + filePath + " deleted = " + deleteSuccessful);
}
} catch (final Exception ignored) {
Log.e(TAG, "Failed to delete file.", ignored);
}
}
} }

Some files were not shown because too many files have changed in this diff Show more