mirror of
https://github.com/TeamNewPipe/NewPipe
synced 2024-12-22 16:10:31 +00:00
Merge pull request #8231 from TeamNewPipe/release/0.23.0
Release 0.23.0 (986)
This commit is contained in:
commit
ecb8ef6bb1
2
.github/CONTRIBUTING.md
vendored
2
.github/CONTRIBUTING.md
vendored
@ -68,7 +68,7 @@ The [checkStyle](https://github.com/checkstyle/checkstyle) plugin verifies that
|
||||
- Go to `File -> Settings -> Plugins`, search for `checkstyle` and install `CheckStyle-IDEA`.
|
||||
- Go to `File -> Settings -> Tools -> Checkstyle`.
|
||||
- Add NewPipe's configuration file by clicking the `+` in the right toolbar of the "Configuration File" list.
|
||||
- Under the "Use a local Checkstyle file" bullet, click on `Browse` and pick the file named `checkstyle.xml` in the project's root folder.
|
||||
- Under the "Use a local Checkstyle file" bullet, click on `Browse` and, enter `checkstyle` folder under the project's root path and pick the file named `checkstyle.xml`.
|
||||
- Enable "Store relative to project location" so that moving the directory around does not create issues.
|
||||
- Insert a description in the top bar, then click `Next` and then `Finish`.
|
||||
- Activate the configuration file you just added by enabling the checkbox on the left.
|
||||
|
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
@ -6,6 +6,7 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
- release/**
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- 'doc/**'
|
||||
@ -31,7 +32,7 @@ jobs:
|
||||
build-and-test-jvm:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- uses: gradle/wrapper-validation-action@v1
|
||||
|
||||
- name: create and checkout branch
|
||||
@ -40,7 +41,7 @@ jobs:
|
||||
run: git checkout -B ${{ github.head_ref }}
|
||||
|
||||
- name: set up JDK 11
|
||||
uses: actions/setup-java@v2
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 11
|
||||
distribution: "temurin"
|
||||
@ -50,7 +51,7 @@ jobs:
|
||||
run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: app
|
||||
path: app/build/outputs/apk/debug/*.apk
|
||||
@ -64,10 +65,10 @@ jobs:
|
||||
# api-level 19 is min sdk, but throws errors related to desugaring
|
||||
api-level: [ 21, 29 ]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: set up JDK 11
|
||||
uses: actions/setup-java@v2
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 11
|
||||
distribution: "temurin"
|
||||
@ -82,7 +83,7 @@ jobs:
|
||||
script: ./gradlew connectedCheck --stacktrace
|
||||
|
||||
- name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v3
|
||||
if: failure()
|
||||
with:
|
||||
name: android-test-report-api${{ matrix.api-level }}
|
||||
@ -91,19 +92,19 @@ jobs:
|
||||
sonar:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: Set up JDK 11
|
||||
uses: actions/setup-java@v2
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 11 # Sonar requires JDK 11
|
||||
distribution: "temurin"
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Cache SonarCloud packages
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.sonar/cache
|
||||
key: ${{ runner.os }}-sonar
|
||||
|
6
.github/workflows/image-minimizer.yml
vendored
6
.github/workflows/image-minimizer.yml
vendored
@ -11,9 +11,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-node@v2
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
@ -21,7 +21,7 @@ jobs:
|
||||
run: npm i probe-image-size@7.2.3 --ignore-scripts
|
||||
|
||||
- name: Minimize simple images
|
||||
uses: actions/github-script@v5
|
||||
uses: actions/github-script@v6
|
||||
timeout-minutes: 3
|
||||
with:
|
||||
script: |
|
||||
|
8
LICENSE
8
LICENSE
@ -1,7 +1,7 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
@ -645,7 +645,7 @@ the "copyright" line and a pointer to where the full notice is found.
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
@ -664,11 +664,11 @@ might be different; for a GUI interface, you would use an "about box".
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
@ -9,15 +9,15 @@ plugins {
|
||||
|
||||
android {
|
||||
compileSdk 31
|
||||
buildToolsVersion '30.0.3'
|
||||
buildToolsVersion '31.0.0'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "org.schabi.newpipe"
|
||||
resValue "string", "app_name", "NewPipe"
|
||||
minSdk 19
|
||||
targetSdk 29
|
||||
versionCode 985
|
||||
versionName "0.22.2"
|
||||
versionCode 986
|
||||
versionName "0.23.0"
|
||||
|
||||
multiDexEnabled true
|
||||
|
||||
@ -98,13 +98,14 @@ android {
|
||||
}
|
||||
|
||||
ext {
|
||||
checkstyleVersion = '9.2.1'
|
||||
checkstyleVersion = '10.0'
|
||||
|
||||
androidxLifecycleVersion = '2.3.1'
|
||||
androidxRoomVersion = '2.3.0'
|
||||
androidxRoomVersion = '2.4.2'
|
||||
androidxWorkVersion = '2.7.1'
|
||||
|
||||
icepickVersion = '3.2.0'
|
||||
exoPlayerVersion = '2.14.2'
|
||||
exoPlayerVersion = '2.17.1'
|
||||
googleAutoServiceVersion = '1.0.1'
|
||||
groupieVersion = '2.10.0'
|
||||
markwonVersion = '4.6.2'
|
||||
@ -121,7 +122,7 @@ configurations {
|
||||
}
|
||||
|
||||
checkstyle {
|
||||
getConfigDirectory().set(rootProject.file("."))
|
||||
getConfigDirectory().set(rootProject.file("checkstyle"))
|
||||
ignoreFailures false
|
||||
showViolations true
|
||||
toolVersion = checkstyleVersion
|
||||
@ -189,11 +190,11 @@ dependencies {
|
||||
// name and the commit hash with the commit hash of the (pushed) commit you want to test
|
||||
// This works thanks to JitPack: https://jitpack.io/
|
||||
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
|
||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:b77c72fb8826c3ffca0be5f96b066cca0a07b1c9'
|
||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:ac1c22d81c65b7b0c5427f4e1989f5256d617f32'
|
||||
|
||||
/** Checkstyle **/
|
||||
checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}"
|
||||
ktlint 'com.pinterest:ktlint:0.43.2'
|
||||
ktlint 'com.pinterest:ktlint:0.44.0'
|
||||
|
||||
/** Kotlin **/
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}"
|
||||
@ -201,16 +202,16 @@ dependencies {
|
||||
/** AndroidX **/
|
||||
implementation 'androidx.appcompat:appcompat:1.3.1'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.2'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
|
||||
implementation 'androidx.core:core-ktx:1.6.0'
|
||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.3.6'
|
||||
implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}"
|
||||
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0'
|
||||
implementation 'androidx.media:media:1.4.3'
|
||||
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
|
||||
implementation 'androidx.media:media:1.5.0'
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
implementation 'androidx.preference:preference:1.1.1'
|
||||
implementation 'androidx.preference:preference:1.2.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
implementation "androidx.room:room-runtime:${androidxRoomVersion}"
|
||||
implementation "androidx.room:room-rxjava3:${androidxRoomVersion}"
|
||||
@ -220,7 +221,10 @@ dependencies {
|
||||
// https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
||||
implementation 'androidx.webkit:webkit:1.4.0'
|
||||
implementation 'com.google.android.material:material:1.4.0'
|
||||
implementation 'com.google.android.material:material:1.5.0'
|
||||
implementation "androidx.work:work-runtime:${androidxWorkVersion}"
|
||||
implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}"
|
||||
implementation "androidx.work:work-rxjava3:${androidxWorkVersion}"
|
||||
|
||||
/** Third-party libraries **/
|
||||
// Instance state boilerplate elimination
|
||||
@ -246,8 +250,6 @@ dependencies {
|
||||
implementation "com.github.lisawray.groupie:groupie:${groupieVersion}"
|
||||
implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}"
|
||||
|
||||
// Circular ImageView
|
||||
implementation "de.hdodenhof:circleimageview:3.1.0"
|
||||
// Image loading
|
||||
//noinspection GradleDependency --> 2.8 is the last version, not 2.71828!
|
||||
implementation "com.squareup.picasso:picasso:2.8"
|
||||
|
3
app/proguard-rules.pro
vendored
3
app/proguard-rules.pro
vendored
@ -51,3 +51,6 @@
|
||||
private void writeObject(java.io.ObjectOutputStream);
|
||||
private void readObject(java.io.ObjectInputStream);
|
||||
}
|
||||
|
||||
# for some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml)
|
||||
-keep class org.schabi.newpipe.settings.notifications.** { *; }
|
||||
|
719
app/schemas/org.schabi.newpipe.database.AppDatabase/5.json
Normal file
719
app/schemas/org.schabi.newpipe.database.AppDatabase/5.json
Normal file
@ -0,0 +1,719 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 5,
|
||||
"identityHash": "096731b513bb71dd44517639f4a2c1e3",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "subscriptions",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "service_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "avatarUrl",
|
||||
"columnName": "avatar_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriberCount",
|
||||
"columnName": "subscriber_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationMode",
|
||||
"columnName": "notification_mode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_subscriptions_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "search_history",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "creationDate",
|
||||
"columnName": "creation_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "service_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "search",
|
||||
"columnName": "search",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_search_history_search",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"search"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "streams",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "service_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "streamType",
|
||||
"columnName": "stream_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uploader",
|
||||
"columnName": "uploader",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uploaderUrl",
|
||||
"columnName": "uploader_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnail_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "viewCount",
|
||||
"columnName": "view_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "textualUploadDate",
|
||||
"columnName": "textual_upload_date",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "uploadDate",
|
||||
"columnName": "upload_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isUploadDateApproximation",
|
||||
"columnName": "is_upload_date_approximation",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_streams_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "stream_history",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "streamUid",
|
||||
"columnName": "stream_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accessDate",
|
||||
"columnName": "access_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "repeatCount",
|
||||
"columnName": "repeat_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"stream_id",
|
||||
"access_date"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_stream_history_stream_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "streams",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"stream_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "stream_state",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "streamUid",
|
||||
"columnName": "stream_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "progressMillis",
|
||||
"columnName": "progress_time",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "streams",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"stream_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "playlists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnail_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_playlists_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`name`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "playlist_stream_join",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "playlistUid",
|
||||
"columnName": "playlist_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "streamUid",
|
||||
"columnName": "stream_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "index",
|
||||
"columnName": "join_index",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"playlist_id",
|
||||
"join_index"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_playlist_stream_join_playlist_id_join_index",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"playlist_id",
|
||||
"join_index"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)"
|
||||
},
|
||||
{
|
||||
"name": "index_playlist_stream_join_stream_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "playlists",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"playlist_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "streams",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"stream_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "remote_playlists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "service_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnail_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "uploader",
|
||||
"columnName": "uploader",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "streamCount",
|
||||
"columnName": "stream_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_remote_playlists_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_remote_playlists_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "feed",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "streamId",
|
||||
"columnName": "stream_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscription_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"stream_id",
|
||||
"subscription_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_feed_subscription_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "streams",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"stream_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "subscriptions",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"subscription_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "feed_group",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "icon",
|
||||
"columnName": "icon_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sort_order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"uid"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_feed_group_sort_order",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sort_order"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "feed_group_subscription_join",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "feedGroupId",
|
||||
"columnName": "group_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscription_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"group_id",
|
||||
"subscription_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_feed_group_subscription_join_subscription_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "feed_group",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"group_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "subscriptions",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"subscription_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "feed_last_updated",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscription_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastUpdated",
|
||||
"columnName": "last_updated",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "subscriptions",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"subscription_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '096731b513bb71dd44517639f4a2c1e3')"
|
||||
]
|
||||
}
|
||||
}
|
@ -0,0 +1,130 @@
|
||||
package org.schabi.newpipe.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.room.Room
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DatabaseMigrationTest {
|
||||
companion object {
|
||||
private const val DEFAULT_SERVICE_ID = 0
|
||||
private const val DEFAULT_URL = "https://www.youtube.com/watch?v=cDphUib5iG4"
|
||||
private const val DEFAULT_TITLE = "Test Title"
|
||||
private val DEFAULT_TYPE = StreamType.VIDEO_STREAM
|
||||
private const val DEFAULT_DURATION = 480L
|
||||
private const val DEFAULT_UPLOADER_NAME = "Uploader Test"
|
||||
private const val DEFAULT_THUMBNAIL = "https://example.com/example.jpg"
|
||||
|
||||
private const val DEFAULT_SECOND_SERVICE_ID = 0
|
||||
private const val DEFAULT_SECOND_URL = "https://www.youtube.com/watch?v=ncQU6iBn5Fc"
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val testHelper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java.canonicalName, FrameworkSQLiteOpenHelperFactory()
|
||||
)
|
||||
|
||||
@Test
|
||||
fun migrateDatabaseFrom2to3() {
|
||||
val databaseInV2 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_2)
|
||||
|
||||
databaseInV2.run {
|
||||
insert(
|
||||
"streams", SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", DEFAULT_SERVICE_ID)
|
||||
put("url", DEFAULT_URL)
|
||||
put("title", DEFAULT_TITLE)
|
||||
put("stream_type", DEFAULT_TYPE.name)
|
||||
put("duration", DEFAULT_DURATION)
|
||||
put("uploader", DEFAULT_UPLOADER_NAME)
|
||||
put("thumbnail_url", DEFAULT_THUMBNAIL)
|
||||
}
|
||||
)
|
||||
insert(
|
||||
"streams", SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", DEFAULT_SECOND_SERVICE_ID)
|
||||
put("url", DEFAULT_SECOND_URL)
|
||||
}
|
||||
)
|
||||
insert(
|
||||
"streams", SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", DEFAULT_SERVICE_ID)
|
||||
}
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME, Migrations.DB_VER_3,
|
||||
true, Migrations.MIGRATION_2_3
|
||||
)
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME, Migrations.DB_VER_4,
|
||||
true, Migrations.MIGRATION_3_4
|
||||
)
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME, Migrations.DB_VER_5,
|
||||
true, Migrations.MIGRATION_4_5
|
||||
)
|
||||
|
||||
val migratedDatabaseV3 = getMigratedDatabase()
|
||||
val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst()
|
||||
|
||||
// Only expect 2, the one with the null url will be ignored
|
||||
assertEquals(2, listFromDB.size)
|
||||
|
||||
val streamFromMigratedDatabase = listFromDB[0]
|
||||
assertEquals(DEFAULT_SERVICE_ID, streamFromMigratedDatabase.serviceId)
|
||||
assertEquals(DEFAULT_URL, streamFromMigratedDatabase.url)
|
||||
assertEquals(DEFAULT_TITLE, streamFromMigratedDatabase.title)
|
||||
assertEquals(DEFAULT_TYPE, streamFromMigratedDatabase.streamType)
|
||||
assertEquals(DEFAULT_DURATION, streamFromMigratedDatabase.duration)
|
||||
assertEquals(DEFAULT_UPLOADER_NAME, streamFromMigratedDatabase.uploader)
|
||||
assertEquals(DEFAULT_THUMBNAIL, streamFromMigratedDatabase.thumbnailUrl)
|
||||
assertNull(streamFromMigratedDatabase.viewCount)
|
||||
assertNull(streamFromMigratedDatabase.textualUploadDate)
|
||||
assertNull(streamFromMigratedDatabase.uploadDate)
|
||||
assertNull(streamFromMigratedDatabase.isUploadDateApproximation)
|
||||
|
||||
val secondStreamFromMigratedDatabase = listFromDB[1]
|
||||
assertEquals(DEFAULT_SECOND_SERVICE_ID, secondStreamFromMigratedDatabase.serviceId)
|
||||
assertEquals(DEFAULT_SECOND_URL, secondStreamFromMigratedDatabase.url)
|
||||
assertEquals("", secondStreamFromMigratedDatabase.title)
|
||||
// Should fallback to VIDEO_STREAM
|
||||
assertEquals(StreamType.VIDEO_STREAM, secondStreamFromMigratedDatabase.streamType)
|
||||
assertEquals(0, secondStreamFromMigratedDatabase.duration)
|
||||
assertEquals("", secondStreamFromMigratedDatabase.uploader)
|
||||
assertEquals("", secondStreamFromMigratedDatabase.thumbnailUrl)
|
||||
assertNull(secondStreamFromMigratedDatabase.viewCount)
|
||||
assertNull(secondStreamFromMigratedDatabase.textualUploadDate)
|
||||
assertNull(secondStreamFromMigratedDatabase.uploadDate)
|
||||
assertNull(secondStreamFromMigratedDatabase.isUploadDateApproximation)
|
||||
}
|
||||
|
||||
private fun getMigratedDatabase(): AppDatabase {
|
||||
val database: AppDatabase = Room.databaseBuilder(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
AppDatabase::class.java, AppDatabase.DATABASE_NAME
|
||||
)
|
||||
.build()
|
||||
testHelper.closeWhenFinished(database)
|
||||
return database
|
||||
}
|
||||
}
|
@ -0,0 +1,188 @@
|
||||
package org.schabi.newpipe.util
|
||||
|
||||
import android.content.Context
|
||||
import android.util.SparseArray
|
||||
import android.view.View
|
||||
import android.view.View.GONE
|
||||
import android.view.View.INVISIBLE
|
||||
import android.view.View.VISIBLE
|
||||
import android.widget.Spinner
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import androidx.test.internal.runner.junit4.statement.UiThreadStatement
|
||||
import org.junit.Assert
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.MediaFormat
|
||||
import org.schabi.newpipe.extractor.stream.AudioStream
|
||||
import org.schabi.newpipe.extractor.stream.Stream
|
||||
import org.schabi.newpipe.extractor.stream.SubtitlesStream
|
||||
import org.schabi.newpipe.extractor.stream.VideoStream
|
||||
|
||||
@MediumTest
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class StreamItemAdapterTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var spinner: Spinner
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
UiThreadStatement.runOnUiThread {
|
||||
spinner = Spinner(context)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoStreams_noSecondaryStream() {
|
||||
val adapter = StreamItemAdapter<VideoStream, AudioStream>(
|
||||
context,
|
||||
getVideoStreams(true, true, true, true),
|
||||
null
|
||||
)
|
||||
|
||||
spinner.adapter = adapter
|
||||
assertIconVisibility(spinner, 0, VISIBLE, VISIBLE)
|
||||
assertIconVisibility(spinner, 1, VISIBLE, VISIBLE)
|
||||
assertIconVisibility(spinner, 2, VISIBLE, VISIBLE)
|
||||
assertIconVisibility(spinner, 3, VISIBLE, VISIBLE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoStreams_hasSecondaryStream() {
|
||||
val adapter = StreamItemAdapter(
|
||||
context,
|
||||
getVideoStreams(false, true, false, true),
|
||||
getAudioStreams(false, true, false, true)
|
||||
)
|
||||
|
||||
spinner.adapter = adapter
|
||||
assertIconVisibility(spinner, 0, GONE, GONE)
|
||||
assertIconVisibility(spinner, 1, GONE, GONE)
|
||||
assertIconVisibility(spinner, 2, GONE, GONE)
|
||||
assertIconVisibility(spinner, 3, GONE, GONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun videoStreams_Mixed() {
|
||||
val adapter = StreamItemAdapter(
|
||||
context,
|
||||
getVideoStreams(true, true, true, true, true, false, true, true),
|
||||
getAudioStreams(false, true, false, false, false, true, true, true)
|
||||
)
|
||||
|
||||
spinner.adapter = adapter
|
||||
assertIconVisibility(spinner, 0, VISIBLE, VISIBLE)
|
||||
assertIconVisibility(spinner, 1, GONE, INVISIBLE)
|
||||
assertIconVisibility(spinner, 2, VISIBLE, VISIBLE)
|
||||
assertIconVisibility(spinner, 3, VISIBLE, VISIBLE)
|
||||
assertIconVisibility(spinner, 4, VISIBLE, VISIBLE)
|
||||
assertIconVisibility(spinner, 5, GONE, INVISIBLE)
|
||||
assertIconVisibility(spinner, 6, GONE, INVISIBLE)
|
||||
assertIconVisibility(spinner, 7, GONE, INVISIBLE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subtitleStreams_noIcon() {
|
||||
val adapter = StreamItemAdapter<SubtitlesStream, Stream>(
|
||||
context,
|
||||
StreamItemAdapter.StreamSizeWrapper(
|
||||
(0 until 5).map {
|
||||
SubtitlesStream(MediaFormat.SRT, "pt-BR", "https://example.com", false)
|
||||
},
|
||||
context
|
||||
),
|
||||
null
|
||||
)
|
||||
spinner.adapter = adapter
|
||||
for (i in 0 until spinner.count) {
|
||||
assertIconVisibility(spinner, i, GONE, GONE)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioStreams_noIcon() {
|
||||
val adapter = StreamItemAdapter<AudioStream, Stream>(
|
||||
context,
|
||||
StreamItemAdapter.StreamSizeWrapper(
|
||||
(0 until 5).map { AudioStream("https://example.com/$it", MediaFormat.OPUS, 192) },
|
||||
context
|
||||
),
|
||||
null
|
||||
)
|
||||
spinner.adapter = adapter
|
||||
for (i in 0 until spinner.count) {
|
||||
assertIconVisibility(spinner, i, GONE, GONE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return a list of video streams, in which their video only property mirrors the provided
|
||||
* [videoOnly] vararg.
|
||||
*/
|
||||
private fun getVideoStreams(vararg videoOnly: Boolean) =
|
||||
StreamItemAdapter.StreamSizeWrapper(
|
||||
videoOnly.map {
|
||||
VideoStream("https://example.com", MediaFormat.MPEG_4, "720p", it)
|
||||
},
|
||||
context
|
||||
)
|
||||
|
||||
/**
|
||||
* @return a list of audio streams, containing valid and null elements mirroring the provided
|
||||
* [shouldBeValid] vararg.
|
||||
*/
|
||||
private fun getAudioStreams(vararg shouldBeValid: Boolean) =
|
||||
getSecondaryStreamsFromList(
|
||||
shouldBeValid.map {
|
||||
if (it) AudioStream("https://example.com", MediaFormat.OPUS, 192)
|
||||
else null
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Checks whether the item at [position] in the [spinner] has the correct icon visibility when
|
||||
* it is shown in normal mode (selected) and in dropdown mode (user is choosing one of a list).
|
||||
*/
|
||||
private fun assertIconVisibility(
|
||||
spinner: Spinner,
|
||||
position: Int,
|
||||
normalVisibility: Int,
|
||||
dropDownVisibility: Int
|
||||
) {
|
||||
spinner.setSelection(position)
|
||||
spinner.adapter.getView(position, null, spinner).run {
|
||||
Assert.assertEquals(
|
||||
"normal visibility (pos=[$position]) is not correct",
|
||||
findViewById<View>(R.id.wo_sound_icon).visibility,
|
||||
normalVisibility,
|
||||
)
|
||||
}
|
||||
spinner.adapter.getDropDownView(position, null, spinner).run {
|
||||
Assert.assertEquals(
|
||||
"drop down visibility (pos=[$position]) is not correct",
|
||||
findViewById<View>(R.id.wo_sound_icon).visibility,
|
||||
dropDownVisibility
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function that builds a secondary stream list.
|
||||
*/
|
||||
private fun <T : Stream> getSecondaryStreamsFromList(streams: List<T?>) =
|
||||
SparseArray<SecondaryStreamHelper<T>?>(streams.size).apply {
|
||||
streams.forEachIndexed { index, stream ->
|
||||
val secondaryStreamHelper: SecondaryStreamHelper<T>? = stream?.let {
|
||||
SecondaryStreamHelper(
|
||||
StreamItemAdapter.StreamSizeWrapper(streams, context),
|
||||
it
|
||||
)
|
||||
}
|
||||
put(index, secondaryStreamHelper)
|
||||
}
|
||||
}
|
||||
}
|
@ -381,9 +381,6 @@
|
||||
<service
|
||||
android:name=".RouterActivity$FetcherService"
|
||||
android:exported="false" />
|
||||
<service
|
||||
android:name=".CheckForNewAppVersion"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- opting out of sending metrics to Google in Android System WebView -->
|
||||
<meta-data android:name="android.webkit.WebView.MetricsOptOut" android:value="true" />
|
||||
|
@ -27,7 +27,7 @@ import org.schabi.newpipe.util.StateSaver;
|
||||
import java.io.IOException;
|
||||
import java.io.InterruptedIOException;
|
||||
import java.net.SocketException;
|
||||
import java.util.Arrays;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@ -213,37 +213,44 @@ public class App extends MultiDexApplication {
|
||||
private void initNotificationChannels() {
|
||||
// Keep the importance below DEFAULT to avoid making noise on every notification update for
|
||||
// the main and update channels
|
||||
final NotificationChannelCompat mainChannel = new NotificationChannelCompat
|
||||
final List<NotificationChannelCompat> notificationChannelCompats = new ArrayList<>();
|
||||
notificationChannelCompats.add(new NotificationChannelCompat
|
||||
.Builder(getString(R.string.notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.notification_channel_name))
|
||||
.setDescription(getString(R.string.notification_channel_description))
|
||||
.build();
|
||||
.build());
|
||||
|
||||
final NotificationChannelCompat appUpdateChannel = new NotificationChannelCompat
|
||||
notificationChannelCompats.add(new NotificationChannelCompat
|
||||
.Builder(getString(R.string.app_update_notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.app_update_notification_channel_name))
|
||||
.setDescription(getString(R.string.app_update_notification_channel_description))
|
||||
.build();
|
||||
.build());
|
||||
|
||||
final NotificationChannelCompat hashChannel = new NotificationChannelCompat
|
||||
notificationChannelCompats.add(new NotificationChannelCompat
|
||||
.Builder(getString(R.string.hash_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||
.setName(getString(R.string.hash_channel_name))
|
||||
.setDescription(getString(R.string.hash_channel_description))
|
||||
.build();
|
||||
.build());
|
||||
|
||||
final NotificationChannelCompat errorReportChannel = new NotificationChannelCompat
|
||||
notificationChannelCompats.add(new NotificationChannelCompat
|
||||
.Builder(getString(R.string.error_report_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.error_report_channel_name))
|
||||
.setDescription(getString(R.string.error_report_channel_description))
|
||||
.build();
|
||||
.build());
|
||||
|
||||
notificationChannelCompats.add(new NotificationChannelCompat
|
||||
.Builder(getString(R.string.streams_notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
||||
.setName(getString(R.string.streams_notification_channel_name))
|
||||
.setDescription(getString(R.string.streams_notification_channel_description))
|
||||
.build());
|
||||
|
||||
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
|
||||
notificationManager.createNotificationChannelsCompat(Arrays.asList(mainChannel,
|
||||
appUpdateChannel, hashChannel, errorReportChannel));
|
||||
notificationManager.createNotificationChannelsCompat(notificationChannelCompats);
|
||||
}
|
||||
|
||||
protected boolean isDisposedRxExceptionsReported() {
|
||||
|
@ -1,264 +0,0 @@
|
||||
package org.schabi.newpipe;
|
||||
|
||||
import android.app.Application;
|
||||
import android.app.IntentService;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.Signature;
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.core.content.pm.PackageInfoCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import com.grack.nanojson.JsonObject;
|
||||
import com.grack.nanojson.JsonParser;
|
||||
import com.grack.nanojson.JsonParserException;
|
||||
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.downloader.Response;
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.List;
|
||||
|
||||
public final class CheckForNewAppVersion extends IntentService {
|
||||
public CheckForNewAppVersion() {
|
||||
super("CheckForNewAppVersion");
|
||||
}
|
||||
|
||||
private static final boolean DEBUG = MainActivity.DEBUG;
|
||||
private static final String TAG = CheckForNewAppVersion.class.getSimpleName();
|
||||
|
||||
// Public key of the certificate that is used in NewPipe release versions
|
||||
private static final String RELEASE_CERT_PUBLIC_KEY_SHA1
|
||||
= "B0:2E:90:7C:1C:D6:FC:57:C3:35:F0:88:D0:8F:50:5F:94:E4:D2:15";
|
||||
private static final String NEWPIPE_API_URL = "https://newpipe.net/api/data.json";
|
||||
|
||||
/**
|
||||
* Method to get the APK's SHA1 key. See https://stackoverflow.com/questions/9293019/#22506133.
|
||||
*
|
||||
* @param application The application
|
||||
* @return String with the APK's SHA1 fingerprint in hexadecimal
|
||||
*/
|
||||
@NonNull
|
||||
private static String getCertificateSHA1Fingerprint(@NonNull final Application application) {
|
||||
final List<Signature> signatures;
|
||||
try {
|
||||
signatures = PackageInfoCompat.getSignatures(application.getPackageManager(),
|
||||
application.getPackageName());
|
||||
} catch (final PackageManager.NameNotFoundException e) {
|
||||
ErrorUtil.createNotification(application, new ErrorInfo(e,
|
||||
UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not find package info"));
|
||||
return "";
|
||||
}
|
||||
if (signatures.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
final X509Certificate c;
|
||||
try {
|
||||
final byte[] cert = signatures.get(0).toByteArray();
|
||||
final InputStream input = new ByteArrayInputStream(cert);
|
||||
final CertificateFactory cf = CertificateFactory.getInstance("X509");
|
||||
c = (X509Certificate) cf.generateCertificate(input);
|
||||
} catch (final CertificateException e) {
|
||||
ErrorUtil.createNotification(application, new ErrorInfo(e,
|
||||
UserAction.CHECK_FOR_NEW_APP_VERSION, "Certificate error"));
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
final MessageDigest md = MessageDigest.getInstance("SHA1");
|
||||
final byte[] publicKey = md.digest(c.getEncoded());
|
||||
return byte2HexFormatted(publicKey);
|
||||
} catch (NoSuchAlgorithmException | CertificateEncodingException e) {
|
||||
ErrorUtil.createNotification(application, new ErrorInfo(e,
|
||||
UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not retrieve SHA1 key"));
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static String byte2HexFormatted(final byte[] arr) {
|
||||
final StringBuilder str = new StringBuilder(arr.length * 2);
|
||||
|
||||
for (int i = 0; i < arr.length; i++) {
|
||||
String h = Integer.toHexString(arr[i]);
|
||||
final int l = h.length();
|
||||
if (l == 1) {
|
||||
h = "0" + h;
|
||||
}
|
||||
if (l > 2) {
|
||||
h = h.substring(l - 2, l);
|
||||
}
|
||||
str.append(h.toUpperCase());
|
||||
if (i < (arr.length - 1)) {
|
||||
str.append(':');
|
||||
}
|
||||
}
|
||||
return str.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to compare the current and latest available app version.
|
||||
* If a newer version is available, we show the update notification.
|
||||
*
|
||||
* @param application The application
|
||||
* @param versionName Name of new version
|
||||
* @param apkLocationUrl Url with the new apk
|
||||
* @param versionCode Code of new version
|
||||
*/
|
||||
private static void compareAppVersionAndShowNotification(@NonNull final Application application,
|
||||
final String versionName,
|
||||
final String apkLocationUrl,
|
||||
final int versionCode) {
|
||||
if (BuildConfig.VERSION_CODE >= versionCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A pending intent to open the apk location url in the browser.
|
||||
final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkLocationUrl));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
final PendingIntent pendingIntent
|
||||
= PendingIntent.getActivity(application, 0, intent, 0);
|
||||
|
||||
final String channelId = application
|
||||
.getString(R.string.app_update_notification_channel_id);
|
||||
final NotificationCompat.Builder notificationBuilder
|
||||
= new NotificationCompat.Builder(application, channelId)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_update)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setContentTitle(application
|
||||
.getString(R.string.app_update_notification_content_title))
|
||||
.setContentText(application
|
||||
.getString(R.string.app_update_notification_content_text)
|
||||
+ " " + versionName);
|
||||
|
||||
final NotificationManagerCompat notificationManager
|
||||
= NotificationManagerCompat.from(application);
|
||||
notificationManager.notify(2000, notificationBuilder.build());
|
||||
}
|
||||
|
||||
public static boolean isReleaseApk(@NonNull final App app) {
|
||||
return getCertificateSHA1Fingerprint(app).equals(RELEASE_CERT_PUBLIC_KEY_SHA1);
|
||||
}
|
||||
|
||||
private void checkNewVersion() throws IOException, ReCaptchaException {
|
||||
final App app = App.getApp();
|
||||
|
||||
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
|
||||
final NewVersionManager manager = new NewVersionManager();
|
||||
|
||||
// Check if the current apk is a github one or not.
|
||||
if (!isReleaseApk(app)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the last request has happened a certain time ago
|
||||
// to reduce the number of API requests.
|
||||
final long expiry = prefs.getLong(app.getString(R.string.update_expiry_key), 0);
|
||||
if (!manager.isExpired(expiry)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make a network request to get latest NewPipe data.
|
||||
final Response response = DownloaderImpl.getInstance().get(NEWPIPE_API_URL);
|
||||
handleResponse(response, manager, prefs, app);
|
||||
}
|
||||
|
||||
private void handleResponse(@NonNull final Response response,
|
||||
@NonNull final NewVersionManager manager,
|
||||
@NonNull final SharedPreferences prefs,
|
||||
@NonNull final App app) {
|
||||
try {
|
||||
// Store a timestamp which needs to be exceeded,
|
||||
// before a new request to the API is made.
|
||||
final long newExpiry = manager
|
||||
.coerceExpiry(response.getHeader("expires"));
|
||||
prefs.edit()
|
||||
.putLong(app.getString(R.string.update_expiry_key), newExpiry)
|
||||
.apply();
|
||||
} catch (final Exception e) {
|
||||
if (DEBUG) {
|
||||
Log.w(TAG, "Could not extract and save new expiry date", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the json from the response.
|
||||
try {
|
||||
|
||||
final JsonObject githubStableObject = JsonParser.object()
|
||||
.from(response.responseBody()).getObject("flavors")
|
||||
.getObject("github").getObject("stable");
|
||||
|
||||
final String versionName = githubStableObject
|
||||
.getString("version");
|
||||
final int versionCode = githubStableObject
|
||||
.getInt("version_code");
|
||||
final String apkLocationUrl = githubStableObject
|
||||
.getString("apk");
|
||||
|
||||
compareAppVersionAndShowNotification(app, versionName,
|
||||
apkLocationUrl, versionCode);
|
||||
} catch (final JsonParserException e) {
|
||||
// Most likely something is wrong in data received from NEWPIPE_API_URL.
|
||||
// Do not alarm user and fail silently.
|
||||
if (DEBUG) {
|
||||
Log.w(TAG, "Could not get NewPipe API: invalid json", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new service which
|
||||
* checks if all conditions for performing a version check are met,
|
||||
* fetches the API endpoint {@link #NEWPIPE_API_URL} containing info
|
||||
* about the latest NewPipe version
|
||||
* and displays a notification about ana available update.
|
||||
* <br>
|
||||
* Following conditions need to be met, before data is request from the server:
|
||||
* <ul>
|
||||
* <li> 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.</li>
|
||||
* <li>The user enabled searching for and notifying about updates in the settings.</li>
|
||||
* <li>The app did not recently check for updates.
|
||||
* We do not want to make unnecessary connections and DOS our servers.</li>
|
||||
* </ul>
|
||||
* <b>Must not be executed</b> when the app is in background.
|
||||
*/
|
||||
public static void startNewVersionCheckService() {
|
||||
final Intent intent = new Intent(App.getApp().getApplicationContext(),
|
||||
CheckForNewAppVersion.class);
|
||||
App.getApp().startService(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onHandleIntent(@Nullable final Intent intent) {
|
||||
try {
|
||||
checkNewVersion();
|
||||
} catch (final IOException e) {
|
||||
Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e);
|
||||
} catch (final ReCaptchaException e) {
|
||||
Log.e(TAG, "ReCaptchaException should never happen here.", e);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -20,7 +20,6 @@
|
||||
|
||||
package org.schabi.newpipe;
|
||||
|
||||
import static org.schabi.newpipe.CheckForNewAppVersion.startNewVersionCheckService;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
@ -72,6 +71,7 @@ import org.schabi.newpipe.fragments.BackPressable;
|
||||
import org.schabi.newpipe.fragments.MainFragment;
|
||||
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
|
||||
import org.schabi.newpipe.fragments.list.search.SearchFragment;
|
||||
import org.schabi.newpipe.local.feed.notifications.NotificationWorker;
|
||||
import org.schabi.newpipe.player.Player;
|
||||
import org.schabi.newpipe.player.event.OnKeyDownListener;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
@ -159,11 +159,14 @@ public class MainActivity extends AppCompatActivity {
|
||||
} catch (final Exception e) {
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Setting up drawer", e);
|
||||
}
|
||||
|
||||
if (DeviceUtils.isTv(this)) {
|
||||
FocusOverlayView.setupFocusObserver(this);
|
||||
}
|
||||
openMiniPlayerUponPlayerStarted();
|
||||
|
||||
// Schedule worker for checking for new streams and creating corresponding notifications
|
||||
// if this is enabled by the user.
|
||||
NotificationWorker.initialize(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -174,10 +177,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
|
||||
|
||||
if (prefs.getBoolean(app.getString(R.string.update_app_key), true)) {
|
||||
// Start the service which is checking all conditions
|
||||
// Start the worker which is checking all conditions
|
||||
// and eventually searching for a new version.
|
||||
// The service searching for a new NewPipe version must not be started in background.
|
||||
startNewVersionCheckService();
|
||||
NewVersionWorker.enqueueNewVersionCheckingWork(app);
|
||||
}
|
||||
}
|
||||
|
||||
@ -227,7 +229,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
drawerLayoutBinding.navigation.getMenu()
|
||||
.add(R.id.menu_tabs_group, kioskId, 0, KioskTranslator
|
||||
.getTranslatedKioskName(ks, this))
|
||||
.setIcon(KioskTranslator.getKioskIcon(ks, this));
|
||||
.setIcon(KioskTranslator.getKioskIcon(ks));
|
||||
kioskId++;
|
||||
}
|
||||
|
||||
@ -719,7 +721,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (toggle != null) {
|
||||
toggle.syncState();
|
||||
toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> mainBinding.getRoot()
|
||||
.openDrawer(GravityCompat.START));
|
||||
.open());
|
||||
mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED);
|
||||
}
|
||||
} else {
|
||||
|
@ -1,5 +1,11 @@
|
||||
package org.schabi.newpipe;
|
||||
|
||||
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
|
||||
@ -8,11 +14,6 @@ import androidx.room.Room;
|
||||
|
||||
import org.schabi.newpipe.database.AppDatabase;
|
||||
|
||||
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
|
||||
|
||||
public final class NewPipeDatabase {
|
||||
private static volatile AppDatabase databaseInstance;
|
||||
|
||||
@ -23,7 +24,7 @@ public final class NewPipeDatabase {
|
||||
private static AppDatabase getDatabase(final Context context) {
|
||||
return Room
|
||||
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
@ -1,28 +0,0 @@
|
||||
package org.schabi.newpipe
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
class NewVersionManager {
|
||||
|
||||
fun isExpired(expiry: Long): Boolean {
|
||||
return Instant.ofEpochSecond(expiry).isBefore(Instant.now())
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce expiry date time in between 6 hours and 72 hours from now
|
||||
*
|
||||
* @return Epoch second of expiry date time
|
||||
*/
|
||||
fun coerceExpiry(expiryString: String?): Long {
|
||||
val now = ZonedDateTime.now()
|
||||
return expiryString?.let {
|
||||
|
||||
var expiry = ZonedDateTime.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(expiryString))
|
||||
expiry = maxOf(expiry, now.plusHours(6))
|
||||
expiry = minOf(expiry, now.plusHours(72))
|
||||
expiry.toEpochSecond()
|
||||
} ?: now.plusHours(6).toEpochSecond()
|
||||
}
|
||||
}
|
163
app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt
Normal file
163
app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt
Normal file
@ -0,0 +1,163 @@
|
||||
package org.schabi.newpipe
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.work.OneTimeWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkRequest
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.grack.nanojson.JsonParser
|
||||
import com.grack.nanojson.JsonParserException
|
||||
import org.schabi.newpipe.extractor.downloader.Response
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||
import org.schabi.newpipe.util.ReleaseVersionUtil.coerceUpdateCheckExpiry
|
||||
import org.schabi.newpipe.util.ReleaseVersionUtil.isLastUpdateCheckExpired
|
||||
import org.schabi.newpipe.util.ReleaseVersionUtil.isReleaseApk
|
||||
import java.io.IOException
|
||||
|
||||
class NewVersionWorker(
|
||||
context: Context,
|
||||
workerParams: WorkerParameters
|
||||
) : Worker(context, workerParams) {
|
||||
|
||||
/**
|
||||
* Method to compare the current and latest available app version.
|
||||
* If a newer version is available, we show the update notification.
|
||||
*
|
||||
* @param versionName Name of new version
|
||||
* @param apkLocationUrl Url with the new apk
|
||||
* @param versionCode Code of new version
|
||||
*/
|
||||
private fun compareAppVersionAndShowNotification(
|
||||
versionName: String,
|
||||
apkLocationUrl: String?,
|
||||
versionCode: Int
|
||||
) {
|
||||
if (BuildConfig.VERSION_CODE >= versionCode) {
|
||||
return
|
||||
}
|
||||
val app = App.getApp()
|
||||
|
||||
// A pending intent to open the apk location url in the browser.
|
||||
val intent = Intent(Intent.ACTION_VIEW, apkLocationUrl?.toUri())
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val pendingIntent = PendingIntent.getActivity(app, 0, intent, 0)
|
||||
val channelId = app.getString(R.string.app_update_notification_channel_id)
|
||||
val notificationBuilder = NotificationCompat.Builder(app, channelId)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_update)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.setContentTitle(app.getString(R.string.app_update_notification_content_title))
|
||||
.setContentText(
|
||||
app.getString(R.string.app_update_notification_content_text) +
|
||||
" " + versionName
|
||||
)
|
||||
val notificationManager = NotificationManagerCompat.from(app)
|
||||
notificationManager.notify(2000, notificationBuilder.build())
|
||||
}
|
||||
|
||||
@Throws(IOException::class, ReCaptchaException::class)
|
||||
private fun checkNewVersion() {
|
||||
// Check if the current apk is a github one or not.
|
||||
if (!isReleaseApk()) {
|
||||
return
|
||||
}
|
||||
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||
// Check if the last request has happened a certain time ago
|
||||
// to reduce the number of API requests.
|
||||
val expiry = prefs.getLong(applicationContext.getString(R.string.update_expiry_key), 0)
|
||||
if (!isLastUpdateCheckExpired(expiry)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Make a network request to get latest NewPipe data.
|
||||
val response = DownloaderImpl.getInstance().get(NEWPIPE_API_URL)
|
||||
handleResponse(response)
|
||||
}
|
||||
|
||||
private fun handleResponse(response: Response) {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||
try {
|
||||
// Store a timestamp which needs to be exceeded,
|
||||
// before a new request to the API is made.
|
||||
val newExpiry = coerceUpdateCheckExpiry(response.getHeader("expires"))
|
||||
prefs.edit {
|
||||
putLong(applicationContext.getString(R.string.update_expiry_key), newExpiry)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (DEBUG) {
|
||||
Log.w(TAG, "Could not extract and save new expiry date", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the json from the response.
|
||||
try {
|
||||
val githubStableObject = JsonParser.`object`()
|
||||
.from(response.responseBody()).getObject("flavors")
|
||||
.getObject("github").getObject("stable")
|
||||
|
||||
val versionName = githubStableObject.getString("version")
|
||||
val versionCode = githubStableObject.getInt("version_code")
|
||||
val apkLocationUrl = githubStableObject.getString("apk")
|
||||
compareAppVersionAndShowNotification(versionName, apkLocationUrl, versionCode)
|
||||
} catch (e: JsonParserException) {
|
||||
// Most likely something is wrong in data received from NEWPIPE_API_URL.
|
||||
// Do not alarm user and fail silently.
|
||||
if (DEBUG) {
|
||||
Log.w(TAG, "Could not get NewPipe API: invalid json", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
try {
|
||||
checkNewVersion()
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e)
|
||||
return Result.failure()
|
||||
} catch (e: ReCaptchaException) {
|
||||
Log.e(TAG, "ReCaptchaException should never happen here.", e)
|
||||
return Result.failure()
|
||||
}
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val DEBUG = MainActivity.DEBUG
|
||||
private val TAG = NewVersionWorker::class.java.simpleName
|
||||
private const val NEWPIPE_API_URL = "https://newpipe.net/api/data.json"
|
||||
|
||||
/**
|
||||
* Start a new worker which
|
||||
* checks if all conditions for performing a version check are met,
|
||||
* fetches the API endpoint [.NEWPIPE_API_URL] containing info
|
||||
* about the latest NewPipe version
|
||||
* and displays a notification about ana available update.
|
||||
* <br></br>
|
||||
* Following conditions need to be met, before data is request from the server:
|
||||
*
|
||||
* * The app is signed with the correct signing key (by TeamNewPipe / schabi).
|
||||
* If the signing key differs from the one used upstream, the update cannot be installed.
|
||||
* * The user enabled searching for and notifying about updates in the settings.
|
||||
* * The app did not recently check for updates.
|
||||
* We do not want to make unnecessary connections and DOS our servers.
|
||||
*
|
||||
*/
|
||||
@JvmStatic
|
||||
fun enqueueNewVersionCheckingWork(context: Context) {
|
||||
val workRequest: WorkRequest =
|
||||
OneTimeWorkRequest.Builder(NewVersionWorker::class.java).build()
|
||||
WorkManager.getInstance(context).enqueue(workRequest)
|
||||
}
|
||||
}
|
||||
}
|
@ -14,7 +14,7 @@ import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.SaveUploaderUrlHelper;
|
||||
import org.schabi.newpipe.util.SparseItemUtil;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
@ -62,7 +62,8 @@ public final class QueueItemMenuUtil {
|
||||
|
||||
return true;
|
||||
case R.id.menu_item_channel_details:
|
||||
SaveUploaderUrlHelper.saveUploaderUrlIfNeeded(context, item,
|
||||
SparseItemUtil.fetchUploaderUrlIfSparse(context, item.getServiceId(),
|
||||
item.getUrl(), item.getUploaderUrl(),
|
||||
// An intent must be used here.
|
||||
// Opening with FragmentManager transactions is not working,
|
||||
// as PlayQueueActivity doesn't use fragments.
|
||||
|
@ -633,7 +633,7 @@ public class RouterActivity extends AppCompatActivity {
|
||||
.subscribe(result -> {
|
||||
final List<VideoStream> sortedVideoStreams = ListHelper
|
||||
.getSortedStreamVideosList(this, result.getVideoStreams(),
|
||||
result.getVideoOnlyStreams(), false);
|
||||
result.getVideoOnlyStreams(), false, false);
|
||||
final int selectedVideoStreamIndex = ListHelper
|
||||
.getDefaultResolutionIndex(this, sortedVideoStreams);
|
||||
|
||||
|
@ -10,7 +10,6 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
import org.schabi.newpipe.R
|
||||
@ -21,30 +20,28 @@ import org.schabi.newpipe.util.ThemeHelper
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
|
||||
class AboutActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Localization.assureCorrectAppLanguage(this)
|
||||
super.onCreate(savedInstanceState)
|
||||
ThemeHelper.setTheme(this)
|
||||
title = getString(R.string.title_activity_about)
|
||||
|
||||
val aboutBinding = ActivityAboutBinding.inflate(layoutInflater)
|
||||
setContentView(aboutBinding.root)
|
||||
setSupportActionBar(aboutBinding.aboutToolbar)
|
||||
supportActionBar!!.setDisplayHomeAsUpEnabled(true)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
// Create the adapter that will return a fragment for each of the three
|
||||
// primary sections of the activity.
|
||||
val mAboutStateAdapter = AboutStateAdapter(this)
|
||||
|
||||
// Set up the ViewPager with the sections adapter.
|
||||
aboutBinding.aboutViewPager2.adapter = mAboutStateAdapter
|
||||
TabLayoutMediator(
|
||||
aboutBinding.aboutTabLayout,
|
||||
aboutBinding.aboutViewPager2
|
||||
) { tab: TabLayout.Tab, position: Int ->
|
||||
when (position) {
|
||||
POS_ABOUT -> tab.setText(R.string.tab_about)
|
||||
POS_LICENSE -> tab.setText(R.string.tab_licenses)
|
||||
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
|
||||
}
|
||||
) { tab, position ->
|
||||
tab.setText(mAboutStateAdapter.getPageTitle(position))
|
||||
}.attach()
|
||||
}
|
||||
|
||||
@ -75,13 +72,14 @@ class AboutActivity : AppCompatActivity() {
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val aboutBinding = FragmentAboutBinding.inflate(inflater, container, false)
|
||||
aboutBinding.aboutAppVersion.text = BuildConfig.VERSION_NAME
|
||||
aboutBinding.aboutGithubLink.openLink(R.string.github_url)
|
||||
aboutBinding.aboutDonationLink.openLink(R.string.donation_url)
|
||||
aboutBinding.aboutWebsiteLink.openLink(R.string.website_url)
|
||||
aboutBinding.aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url)
|
||||
return aboutBinding.root
|
||||
FragmentAboutBinding.inflate(inflater, container, false).apply {
|
||||
aboutAppVersion.text = BuildConfig.VERSION_NAME
|
||||
aboutGithubLink.openLink(R.string.github_url)
|
||||
aboutDonationLink.openLink(R.string.donation_url)
|
||||
aboutWebsiteLink.openLink(R.string.website_url)
|
||||
aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url)
|
||||
return root
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,17 +88,29 @@ class AboutActivity : AppCompatActivity() {
|
||||
* one of the sections/tabs/pages.
|
||||
*/
|
||||
private class AboutStateAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
|
||||
private val posAbout = 0
|
||||
private val posLicense = 1
|
||||
private val totalCount = 2
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
return when (position) {
|
||||
POS_ABOUT -> AboutFragment()
|
||||
POS_LICENSE -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS)
|
||||
posAbout -> AboutFragment()
|
||||
posLicense -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS)
|
||||
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
// Show 2 total pages.
|
||||
return TOTAL_COUNT
|
||||
return totalCount
|
||||
}
|
||||
|
||||
fun getPageTitle(position: Int): Int {
|
||||
return when (position) {
|
||||
posAbout -> R.string.tab_about
|
||||
posLicense -> R.string.tab_licenses
|
||||
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -117,10 +127,6 @@ class AboutActivity : AppCompatActivity() {
|
||||
"AndroidX", "2005 - 2011", "The Android Open Source Project",
|
||||
"https://developer.android.com/jetpack", StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"CircleImageView", "2014 - 2020", "Henning Dodenhof",
|
||||
"https://github.com/hdodenhof/CircleImageView", StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"ExoPlayer", "2014 - 2020", "Google, Inc.",
|
||||
"https://github.com/google/ExoPlayer", StandardLicenses.APACHE2
|
||||
@ -191,8 +197,5 @@ class AboutActivity : AppCompatActivity() {
|
||||
"https://github.com/ByteHamster/SearchPreference", StandardLicenses.MIT
|
||||
),
|
||||
)
|
||||
private const val POS_ABOUT = 0
|
||||
private const val POS_LICENSE = 1
|
||||
private const val TOTAL_COUNT = 2
|
||||
}
|
||||
}
|
||||
|
@ -87,60 +87,50 @@ object LicenseFragmentHelper {
|
||||
return context.getString(color).substring(3)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun showLicense(context: Context?, license: License): Disposable {
|
||||
return showLicense(context, license) { alertDialog ->
|
||||
alertDialog.setPositiveButton(R.string.ok) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showLicense(context: Context?, component: SoftwareComponent): Disposable {
|
||||
return showLicense(context, component.license) { alertDialog ->
|
||||
alertDialog.setPositiveButton(R.string.dismiss) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
alertDialog.setNeutralButton(R.string.open_website_license) { _, _ ->
|
||||
ShareUtils.openUrlInBrowser(context!!, component.link)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLicense(
|
||||
context: Context?,
|
||||
license: License,
|
||||
block: (AlertDialog.Builder) -> Unit
|
||||
): Disposable {
|
||||
return if (context == null) {
|
||||
Disposable.empty()
|
||||
} else {
|
||||
Observable.fromCallable { getFormattedLicense(context, license) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { formattedLicense: String ->
|
||||
.subscribe { formattedLicense ->
|
||||
val webViewData = Base64.encodeToString(
|
||||
formattedLicense
|
||||
.toByteArray(StandardCharsets.UTF_8),
|
||||
Base64.NO_PADDING
|
||||
formattedLicense.toByteArray(StandardCharsets.UTF_8), Base64.NO_PADDING
|
||||
)
|
||||
val webView = WebView(context)
|
||||
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
|
||||
val alert = AlertDialog.Builder(context)
|
||||
alert.setTitle(license.name)
|
||||
alert.setView(webView)
|
||||
Localization.assureCorrectAppLanguage(context)
|
||||
alert.setNegativeButton(
|
||||
context.getString(R.string.ok)
|
||||
) { dialog, _ -> dialog.dismiss() }
|
||||
alert.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@JvmStatic
|
||||
fun showLicense(context: Context?, component: SoftwareComponent): Disposable {
|
||||
return if (context == null) {
|
||||
Disposable.empty()
|
||||
} else {
|
||||
Observable.fromCallable { getFormattedLicense(context, component.license) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { formattedLicense: String ->
|
||||
val webViewData = Base64.encodeToString(
|
||||
formattedLicense
|
||||
.toByteArray(StandardCharsets.UTF_8),
|
||||
Base64.NO_PADDING
|
||||
)
|
||||
val webView = WebView(context)
|
||||
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
|
||||
val alert = AlertDialog.Builder(context)
|
||||
alert.setTitle(component.license.name)
|
||||
alert.setView(webView)
|
||||
Localization.assureCorrectAppLanguage(context)
|
||||
alert.setPositiveButton(
|
||||
R.string.dismiss
|
||||
) { dialog, _ -> dialog.dismiss() }
|
||||
alert.setNeutralButton(R.string.open_website_license) { _, _ ->
|
||||
ShareUtils.openUrlInBrowser(context, component.link)
|
||||
|
||||
AlertDialog.Builder(context).apply {
|
||||
setTitle(license.name)
|
||||
setView(webView)
|
||||
Localization.assureCorrectAppLanguage(context)
|
||||
block(this)
|
||||
show()
|
||||
}
|
||||
alert.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,7 @@
|
||||
package org.schabi.newpipe.database;
|
||||
|
||||
import static org.schabi.newpipe.database.Migrations.DB_VER_5;
|
||||
|
||||
import androidx.room.Database;
|
||||
import androidx.room.RoomDatabase;
|
||||
import androidx.room.TypeConverters;
|
||||
@ -27,8 +29,6 @@ import org.schabi.newpipe.database.stream.model.StreamStateEntity;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionDAO;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
|
||||
import static org.schabi.newpipe.database.Migrations.DB_VER_4;
|
||||
|
||||
@TypeConverters({Converters.class})
|
||||
@Database(
|
||||
entities = {
|
||||
@ -38,7 +38,7 @@ import static org.schabi.newpipe.database.Migrations.DB_VER_4;
|
||||
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
|
||||
FeedLastUpdatedEntity.class
|
||||
},
|
||||
version = DB_VER_4
|
||||
version = DB_VER_5
|
||||
)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
public static final String DATABASE_NAME = "newpipe.db";
|
||||
|
@ -22,6 +22,7 @@ public final class Migrations {
|
||||
public static final int DB_VER_2 = 2;
|
||||
public static final int DB_VER_3 = 3;
|
||||
public static final int DB_VER_4 = 4;
|
||||
public static final int DB_VER_5 = 5;
|
||||
|
||||
private static final String TAG = Migrations.class.getName();
|
||||
public static final boolean DEBUG = MainActivity.DEBUG;
|
||||
@ -179,5 +180,14 @@ public final class Migrations {
|
||||
}
|
||||
};
|
||||
|
||||
private Migrations() { }
|
||||
public static final Migration MIGRATION_4_5 = new Migration(DB_VER_4, DB_VER_5) {
|
||||
@Override
|
||||
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` "
|
||||
+ "INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
};
|
||||
|
||||
private Migrations() {
|
||||
}
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ import org.schabi.newpipe.database.feed.model.FeedEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
||||
import org.schabi.newpipe.database.stream.StreamWithState
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@ -252,4 +253,21 @@ abstract class FeedDAO {
|
||||
"""
|
||||
)
|
||||
abstract fun getAllOutdatedForGroup(groupId: Long, outdatedThreshold: OffsetDateTime): Flowable<List<SubscriptionEntity>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT s.* FROM subscriptions s
|
||||
|
||||
LEFT JOIN feed_last_updated lu
|
||||
ON s.uid = lu.subscription_id
|
||||
|
||||
WHERE
|
||||
(lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold)
|
||||
AND s.notification_mode = :notificationMode
|
||||
"""
|
||||
)
|
||||
abstract fun getOutdatedWithNotificationMode(
|
||||
outdatedThreshold: OffsetDateTime,
|
||||
@NotificationMode notificationMode: Int
|
||||
): Flowable<List<SubscriptionEntity>>
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package org.schabi.newpipe.database.history.dao;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Query;
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns;
|
||||
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity;
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntry;
|
||||
@ -67,6 +68,7 @@ public abstract class StreamHistoryDAO implements HistoryDAO<StreamHistoryEntity
|
||||
@Query("DELETE FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
|
||||
public abstract int deleteStreamHistory(long streamId);
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query("SELECT * FROM " + STREAM_TABLE
|
||||
|
||||
// Select the latest entry and watch count for each stream id on history table
|
||||
|
@ -2,6 +2,7 @@ package org.schabi.newpipe.database.playlist.dao;
|
||||
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Query;
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns;
|
||||
import androidx.room.Transaction;
|
||||
|
||||
import org.schabi.newpipe.database.BasicDAO;
|
||||
@ -52,6 +53,7 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
|
||||
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
|
||||
Flowable<Integer> getMaximumIndexOf(long playlistId);
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Transaction
|
||||
@Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN "
|
||||
// get ids of streams of the given playlist
|
||||
|
@ -39,6 +39,9 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
internal abstract fun silentInsertAllInternal(streams: List<StreamEntity>): List<Long>
|
||||
|
||||
@Query("SELECT COUNT(*) != 0 FROM streams WHERE url = :url AND service_id = :serviceId")
|
||||
internal abstract fun exists(serviceId: Int, url: String): Boolean
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration
|
||||
|
@ -0,0 +1,14 @@
|
||||
package org.schabi.newpipe.database.subscription;
|
||||
|
||||
import androidx.annotation.IntDef;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
|
||||
@IntDef({NotificationMode.DISABLED, NotificationMode.ENABLED})
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
public @interface NotificationMode {
|
||||
|
||||
int DISABLED = 0;
|
||||
int ENABLED = 1;
|
||||
//other values reserved for the future
|
||||
}
|
@ -4,6 +4,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||
import androidx.room.Transaction
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
@ -31,6 +32,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
||||
)
|
||||
abstract fun getSubscriptionsFiltered(filter: String): Flowable<List<SubscriptionEntity>>
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM subscriptions s
|
||||
@ -47,6 +49,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
||||
currentGroupId: Long
|
||||
): Flowable<List<SubscriptionEntity>>
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM subscriptions s
|
||||
|
@ -26,6 +26,7 @@ public class SubscriptionEntity {
|
||||
public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url";
|
||||
public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
|
||||
public static final String SUBSCRIPTION_DESCRIPTION = "description";
|
||||
public static final String SUBSCRIPTION_NOTIFICATION_MODE = "notification_mode";
|
||||
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
private long uid = 0;
|
||||
@ -48,6 +49,9 @@ public class SubscriptionEntity {
|
||||
@ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
|
||||
private String description;
|
||||
|
||||
@ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE)
|
||||
private int notificationMode;
|
||||
|
||||
@Ignore
|
||||
public static SubscriptionEntity from(@NonNull final ChannelInfo info) {
|
||||
final SubscriptionEntity result = new SubscriptionEntity();
|
||||
@ -114,6 +118,15 @@ public class SubscriptionEntity {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
@NotificationMode
|
||||
public int getNotificationMode() {
|
||||
return notificationMode;
|
||||
}
|
||||
|
||||
public void setNotificationMode(@NotificationMode final int notificationMode) {
|
||||
this.notificationMode = notificationMode;
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public void setData(final String n, final String au, final String d, final Long sc) {
|
||||
this.setName(n);
|
||||
|
@ -61,6 +61,7 @@ import org.schabi.newpipe.util.FilenameUtils;
|
||||
import org.schabi.newpipe.util.ListHelper;
|
||||
import org.schabi.newpipe.util.PermissionHelper;
|
||||
import org.schabi.newpipe.util.SecondaryStreamHelper;
|
||||
import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
|
||||
import org.schabi.newpipe.util.StreamItemAdapter;
|
||||
import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
@ -151,7 +152,7 @@ public class DownloadDialog extends DialogFragment
|
||||
public static DownloadDialog newInstance(final Context context, final StreamInfo info) {
|
||||
final ArrayList<VideoStream> streamsList = new ArrayList<>(ListHelper
|
||||
.getSortedStreamVideosList(context, info.getVideoStreams(),
|
||||
info.getVideoOnlyStreams(), false));
|
||||
info.getVideoOnlyStreams(), false, false));
|
||||
final int selectedStreamIndex = ListHelper.getDefaultResolutionIndex(context, streamsList);
|
||||
|
||||
final DownloadDialog instance = newInstance(info);
|
||||
@ -321,21 +322,15 @@ public class DownloadDialog extends DialogFragment
|
||||
final int threads = prefs.getInt(getString(R.string.default_download_threads), 3);
|
||||
dialogBinding.threadsCount.setText(String.valueOf(threads));
|
||||
dialogBinding.threads.setProgress(threads - 1);
|
||||
dialogBinding.threads.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
|
||||
dialogBinding.threads.setOnSeekBarChangeListener(new SimpleOnSeekBarChangeListener() {
|
||||
@Override
|
||||
public void onProgressChanged(final SeekBar seekbar, final int progress,
|
||||
public void onProgressChanged(@NonNull final SeekBar seekbar, final int progress,
|
||||
final boolean fromUser) {
|
||||
final int newProgress = progress + 1;
|
||||
prefs.edit().putInt(getString(R.string.default_download_threads), newProgress)
|
||||
.apply();
|
||||
dialogBinding.threadsCount.setText(String.valueOf(newProgress));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStartTrackingTouch(final SeekBar p1) { }
|
||||
|
||||
@Override
|
||||
public void onStopTrackingTouch(final SeekBar p1) { }
|
||||
});
|
||||
|
||||
fetchStreamsSize();
|
||||
|
@ -1,7 +1,6 @@
|
||||
package org.schabi.newpipe.error
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@ -10,7 +9,7 @@ import android.os.Build
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.schabi.newpipe.R
|
||||
@ -105,13 +104,6 @@ class ErrorUtil {
|
||||
*/
|
||||
@JvmStatic
|
||||
fun createNotification(context: Context, errorInfo: ErrorInfo) {
|
||||
val notificationManager =
|
||||
ContextCompat.getSystemService(context, NotificationManager::class.java)
|
||||
if (notificationManager == null) {
|
||||
// this should never happen, but just in case open error activity
|
||||
openActivity(context, errorInfo)
|
||||
}
|
||||
|
||||
var pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
pendingIntentFlags = pendingIntentFlags or PendingIntent.FLAG_IMMUTABLE
|
||||
@ -122,7 +114,13 @@ class ErrorUtil {
|
||||
context,
|
||||
context.getString(R.string.error_report_channel_id)
|
||||
)
|
||||
.setSmallIcon(R.drawable.ic_bug_report)
|
||||
.setSmallIcon(
|
||||
// the vector drawable icon causes crashes on KitKat devices
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
|
||||
R.drawable.ic_bug_report
|
||||
else
|
||||
android.R.drawable.stat_notify_error
|
||||
)
|
||||
.setContentTitle(context.getString(R.string.error_report_notification_title))
|
||||
.setContentText(context.getString(errorInfo.messageStringId))
|
||||
.setAutoCancel(true)
|
||||
@ -135,7 +133,8 @@ class ErrorUtil {
|
||||
)
|
||||
)
|
||||
|
||||
notificationManager!!.notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build())
|
||||
NotificationManagerCompat.from(context)
|
||||
.notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build())
|
||||
|
||||
// since the notification is silent, also show a toast, otherwise the user is confused
|
||||
Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT)
|
||||
|
@ -26,10 +26,11 @@ public enum UserAction {
|
||||
DOWNLOAD_OPEN_DIALOG("download open dialog"),
|
||||
DOWNLOAD_POSTPROCESSING("download post-processing"),
|
||||
DOWNLOAD_FAILED("download failed"),
|
||||
NEW_STREAMS_NOTIFICATIONS("new streams notifications"),
|
||||
PREFERENCES_MIGRATION("migration of preferences"),
|
||||
SHARE_TO_NEWPIPE("share to newpipe"),
|
||||
CHECK_FOR_NEW_APP_VERSION("check for new app version");
|
||||
|
||||
CHECK_FOR_NEW_APP_VERSION("check for new app version"),
|
||||
OPEN_INFO_ITEM_DIALOG("open info item dialog");
|
||||
|
||||
private final String message;
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
package org.schabi.newpipe.fragments;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.recyclerview.widget.StaggeredGridLayoutManager;
|
||||
@ -10,7 +11,7 @@ import androidx.recyclerview.widget.StaggeredGridLayoutManager;
|
||||
*/
|
||||
public abstract class OnScrollBelowItemsListener extends RecyclerView.OnScrollListener {
|
||||
@Override
|
||||
public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) {
|
||||
public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) {
|
||||
super.onScrolled(recyclerView, dx, dy);
|
||||
if (dy > 0) {
|
||||
int pastVisibleItems = 0;
|
||||
|
@ -1,5 +1,7 @@
|
||||
package org.schabi.newpipe.fragments.detail;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
|
||||
import java.io.Serializable;
|
||||
@ -46,6 +48,7 @@ class StackItem implements Serializable {
|
||||
return playQueue;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return getServiceId() + ":" + getUrl() + " > " + getTitle();
|
||||
|
@ -43,7 +43,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import com.google.android.exoplayer2.ExoPlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
import com.google.android.material.appbar.AppBarLayout;
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior;
|
||||
@ -1617,6 +1617,7 @@ public final class VideoDetailFragment
|
||||
activity,
|
||||
info.getVideoStreams(),
|
||||
info.getVideoOnlyStreams(),
|
||||
false,
|
||||
false);
|
||||
selectedVideoStreamIndex = ListHelper
|
||||
.getDefaultResolutionIndex(activity, sortedVideoStreams);
|
||||
@ -1883,9 +1884,8 @@ public final class VideoDetailFragment
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerError(final ExoPlaybackException error) {
|
||||
if (error.type == ExoPlaybackException.TYPE_SOURCE
|
||||
|| error.type == ExoPlaybackException.TYPE_UNEXPECTED) {
|
||||
public void onPlayerError(final PlaybackException error, final boolean isCatchableException) {
|
||||
if (!isCatchableException) {
|
||||
// Properly exit from fullscreen
|
||||
toggleFullscreenIfInFullscreenMode();
|
||||
hideMainPlayerOnLoadingNewStream();
|
||||
|
@ -15,6 +15,7 @@ import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import com.google.android.exoplayer2.C;
|
||||
import com.google.android.exoplayer2.ExoPlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
|
||||
@ -28,6 +29,10 @@ import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW;
|
||||
import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_DECODING_FAILED;
|
||||
import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_UNSPECIFIED;
|
||||
|
||||
/**
|
||||
* Outsourced logic for crashing the player in the {@link VideoDetailFragment}.
|
||||
*/
|
||||
@ -51,7 +56,8 @@ public final class VideoDetailPlayerCrasher {
|
||||
exceptionTypes.put(
|
||||
"Source",
|
||||
() -> ExoPlaybackException.createForSource(
|
||||
new IOException(defaultMsg)
|
||||
new IOException(defaultMsg),
|
||||
ERROR_CODE_BEHIND_LIVE_WINDOW
|
||||
)
|
||||
);
|
||||
exceptionTypes.put(
|
||||
@ -61,13 +67,16 @@ public final class VideoDetailPlayerCrasher {
|
||||
"Dummy renderer",
|
||||
0,
|
||||
null,
|
||||
C.FORMAT_HANDLED
|
||||
C.FORMAT_HANDLED,
|
||||
/*isRecoverable=*/false,
|
||||
ERROR_CODE_DECODING_FAILED
|
||||
)
|
||||
);
|
||||
exceptionTypes.put(
|
||||
"Unexpected",
|
||||
() -> ExoPlaybackException.createForUnexpected(
|
||||
new RuntimeException(defaultMsg)
|
||||
new RuntimeException(defaultMsg),
|
||||
ERROR_CODE_UNSPECIFIED
|
||||
)
|
||||
);
|
||||
exceptionTypes.put(
|
||||
@ -139,7 +148,7 @@ public final class VideoDetailPlayerCrasher {
|
||||
|
||||
/**
|
||||
* Note that this method does not crash the underlying exoplayer directly (it's not possible).
|
||||
* It simply supplies a Exception to {@link Player#onPlayerError(ExoPlaybackException)}.
|
||||
* It simply supplies a Exception to {@link Player#onPlayerError(PlaybackException)}.
|
||||
* @param player
|
||||
* @param exception
|
||||
*/
|
||||
|
@ -1,6 +1,8 @@
|
||||
package org.schabi.newpipe.fragments.list;
|
||||
|
||||
import android.app.Activity;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.res.Configuration;
|
||||
@ -25,29 +27,19 @@ import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.fragments.BaseStateFragment;
|
||||
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
|
||||
import org.schabi.newpipe.info_list.InfoItemDialog;
|
||||
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
|
||||
import org.schabi.newpipe.info_list.InfoListAdapter;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.OnClickGesture;
|
||||
import org.schabi.newpipe.util.StateSaver;
|
||||
import org.schabi.newpipe.util.StreamDialogEntry;
|
||||
import org.schabi.newpipe.views.SuperScrollLayoutManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
|
||||
|
||||
public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
implements ListViewContract<I, N>, StateSaver.WriteRead,
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
@ -268,11 +260,11 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
|
||||
@Override
|
||||
public void held(final StreamInfoItem selectedItem) {
|
||||
showStreamDialog(selectedItem);
|
||||
showInfoItemDialog(selectedItem);
|
||||
}
|
||||
});
|
||||
|
||||
infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<ChannelInfoItem>() {
|
||||
infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<>() {
|
||||
@Override
|
||||
public void selected(final ChannelInfoItem selectedItem) {
|
||||
try {
|
||||
@ -288,7 +280,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
}
|
||||
});
|
||||
|
||||
infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture<PlaylistInfoItem>() {
|
||||
infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture<>() {
|
||||
@Override
|
||||
public void selected(final PlaylistInfoItem selectedItem) {
|
||||
try {
|
||||
@ -350,7 +342,8 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
itemsList.clearOnScrollListeners();
|
||||
itemsList.addOnScrollListener(new DefaultItemListOnScrolledDownListener() {
|
||||
@Override
|
||||
public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) {
|
||||
public void onScrolled(@NonNull final RecyclerView recyclerView,
|
||||
final int dx, final int dy) {
|
||||
super.onScrolled(recyclerView, dx, dy);
|
||||
|
||||
if (dy != 0) {
|
||||
@ -409,55 +402,12 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
||||
}
|
||||
}
|
||||
|
||||
protected void showStreamDialog(final StreamInfoItem item) {
|
||||
final Context context = getContext();
|
||||
final Activity activity = getActivity();
|
||||
if (context == null || context.getResources() == null || activity == null) {
|
||||
return;
|
||||
protected void showInfoItemDialog(final StreamInfoItem item) {
|
||||
try {
|
||||
new InfoItemDialog.Builder(getActivity(), getContext(), this, item).create().show();
|
||||
} catch (final IllegalArgumentException e) {
|
||||
InfoItemDialog.Builder.reportErrorDuringInitialization(e, item);
|
||||
}
|
||||
final List<StreamDialogEntry> entries = new ArrayList<>();
|
||||
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
||||
entries.add(StreamDialogEntry.enqueue);
|
||||
|
||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
||||
entries.add(StreamDialogEntry.enqueue_next);
|
||||
}
|
||||
}
|
||||
|
||||
if (item.getStreamType() == StreamType.AUDIO_STREAM) {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
} else {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.start_here_on_popup,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
}
|
||||
entries.add(StreamDialogEntry.open_in_browser);
|
||||
if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) {
|
||||
entries.add(StreamDialogEntry.play_with_kodi);
|
||||
}
|
||||
|
||||
// show "mark as watched" only when watch history is enabled
|
||||
if (StreamDialogEntry.shouldAddMarkAsWatched(item.getStreamType(), context)) {
|
||||
entries.add(
|
||||
StreamDialogEntry.mark_as_watched
|
||||
);
|
||||
}
|
||||
if (!isNullOrEmpty(item.getUploaderUrl())) {
|
||||
entries.add(StreamDialogEntry.show_channel_details);
|
||||
}
|
||||
|
||||
StreamDialogEntry.setEnabledEntries(entries);
|
||||
|
||||
new InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context),
|
||||
(dialog, which) -> StreamDialogEntry.clickOn(which, this, item)).show();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
|
@ -9,6 +9,7 @@ import androidx.annotation.NonNull;
|
||||
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.ListInfo;
|
||||
import org.schabi.newpipe.extractor.Page;
|
||||
@ -27,8 +28,8 @@ import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
extends BaseListFragment<I, ListExtractor.InfoItemsPage> {
|
||||
public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInfo<I>>
|
||||
extends BaseListFragment<L, ListExtractor.InfoItemsPage<I>> {
|
||||
@State
|
||||
protected int serviceId = Constants.NO_SERVICE_ID;
|
||||
@State
|
||||
@ -37,7 +38,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
protected String url;
|
||||
|
||||
private final UserAction errorUserAction;
|
||||
protected I currentInfo;
|
||||
protected L currentInfo;
|
||||
protected Page currentNextPage;
|
||||
protected Disposable currentWorker;
|
||||
|
||||
@ -97,7 +98,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
@SuppressWarnings("unchecked")
|
||||
public void readFrom(@NonNull final Queue<Object> savedObjects) throws Exception {
|
||||
super.readFrom(savedObjects);
|
||||
currentInfo = (I) savedObjects.poll();
|
||||
currentInfo = (L) savedObjects.poll();
|
||||
currentNextPage = (Page) savedObjects.poll();
|
||||
}
|
||||
|
||||
@ -124,7 +125,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
* @param forceLoad allow or disallow the result to come from the cache
|
||||
* @return Rx {@link Single} containing the {@link ListInfo}
|
||||
*/
|
||||
protected abstract Single<I> loadResult(boolean forceLoad);
|
||||
protected abstract Single<L> loadResult(boolean forceLoad);
|
||||
|
||||
@Override
|
||||
public void startLoading(final boolean forceLoad) {
|
||||
@ -140,7 +141,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
currentWorker = loadResult(forceLoad)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe((@NonNull I result) -> {
|
||||
.subscribe((@NonNull L result) -> {
|
||||
isLoading.set(false);
|
||||
currentInfo = result;
|
||||
currentNextPage = result.getNextPage();
|
||||
@ -157,7 +158,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
*
|
||||
* @return Rx {@link Single} containing the {@link ListExtractor.InfoItemsPage}
|
||||
*/
|
||||
protected abstract Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic();
|
||||
protected abstract Single<ListExtractor.InfoItemsPage<I>> loadMoreItemsLogic();
|
||||
|
||||
@Override
|
||||
protected void loadMoreItems() {
|
||||
@ -194,7 +195,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleNextItems(final ListExtractor.InfoItemsPage result) {
|
||||
public void handleNextItems(final ListExtractor.InfoItemsPage<I> result) {
|
||||
super.handleNextItems(result);
|
||||
|
||||
currentNextPage = result.getNextPage();
|
||||
@ -218,7 +219,7 @@ public abstract class BaseListInfoFragment<I extends ListInfo>
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void handleResult(@NonNull final I result) {
|
||||
public void handleResult(@NonNull final L result) {
|
||||
super.handleResult(result);
|
||||
|
||||
name = result.getName();
|
||||
|
@ -5,6 +5,7 @@ import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
@ -22,9 +23,11 @@ import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
import com.jakewharton.rxbinding4.view.RxView;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.databinding.ChannelHeaderBinding;
|
||||
import org.schabi.newpipe.databinding.FragmentChannelBinding;
|
||||
@ -39,6 +42,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.ktx.AnimationType;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||
import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
|
||||
import org.schabi.newpipe.player.MainPlayer.PlayerType;
|
||||
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
@ -64,7 +68,7 @@ import io.reactivex.rxjava3.functions.Consumer;
|
||||
import io.reactivex.rxjava3.functions.Function;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, ChannelInfo>
|
||||
implements View.OnClickListener {
|
||||
|
||||
private static final int BUTTON_DEBOUNCE_INTERVAL = 100;
|
||||
@ -84,6 +88,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
private PlaylistControlBinding playlistControlBinding;
|
||||
|
||||
private MenuItem menuRssButton;
|
||||
private MenuItem menuNotifyButton;
|
||||
|
||||
public static ChannelFragment getInstance(final int serviceId, final String url,
|
||||
final String name) {
|
||||
@ -179,6 +184,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||
}
|
||||
menuRssButton = menu.findItem(R.id.menu_item_rss);
|
||||
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
|
||||
}
|
||||
}
|
||||
|
||||
@ -188,6 +194,11 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
case R.id.action_settings:
|
||||
NavigationHelper.openSettings(requireContext());
|
||||
break;
|
||||
case R.id.menu_item_notify:
|
||||
final boolean value = !item.isChecked();
|
||||
item.setEnabled(false);
|
||||
setNotify(value);
|
||||
break;
|
||||
case R.id.menu_item_rss:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.openUrlInBrowser(
|
||||
@ -232,15 +243,22 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
.subscribe(getSubscribeUpdateMonitor(info), onError));
|
||||
|
||||
disposables.add(observable
|
||||
// Some updates are very rapid
|
||||
// (for example when calling the updateSubscription(info))
|
||||
// so only update the UI for the latest emission
|
||||
// ("sync" the subscribe button's state)
|
||||
.debounce(100, TimeUnit.MILLISECONDS)
|
||||
.map(List::isEmpty)
|
||||
.distinctUntilChanged()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe((List<SubscriptionEntity> subscriptionEntities) ->
|
||||
updateSubscribeButton(!subscriptionEntities.isEmpty()), onError));
|
||||
.subscribe(isEmpty -> updateSubscribeButton(!isEmpty), onError));
|
||||
|
||||
disposables.add(observable
|
||||
.map(List::isEmpty)
|
||||
.distinctUntilChanged()
|
||||
.skip(1) // channel has just been opened
|
||||
.filter(x -> NotificationHelper.areNewStreamsNotificationsEnabled(requireContext()))
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(isEmpty -> {
|
||||
if (!isEmpty) {
|
||||
showNotifySnackbar();
|
||||
}
|
||||
}, onError));
|
||||
}
|
||||
|
||||
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription,
|
||||
@ -320,6 +338,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
info.getAvatarUrl(),
|
||||
info.getDescription(),
|
||||
info.getSubscriberCount());
|
||||
updateNotifyButton(null);
|
||||
subscribeButtonMonitor = monitorSubscribeButton(
|
||||
headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info));
|
||||
} else {
|
||||
@ -327,6 +346,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
Log.d(TAG, "Found subscription to this channel!");
|
||||
}
|
||||
final SubscriptionEntity subscription = subscriptionEntities.get(0);
|
||||
updateNotifyButton(subscription);
|
||||
subscribeButtonMonitor = monitorSubscribeButton(
|
||||
headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription));
|
||||
}
|
||||
@ -369,12 +389,51 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo>
|
||||
AnimationType.LIGHT_SCALE_AND_ALPHA);
|
||||
}
|
||||
|
||||
private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) {
|
||||
if (menuNotifyButton == null) {
|
||||
return;
|
||||
}
|
||||
if (subscription != null) {
|
||||
menuNotifyButton.setEnabled(
|
||||
NotificationHelper.areNewStreamsNotificationsEnabled(requireContext())
|
||||
);
|
||||
menuNotifyButton.setChecked(
|
||||
subscription.getNotificationMode() == NotificationMode.ENABLED
|
||||
);
|
||||
}
|
||||
|
||||
menuNotifyButton.setVisible(subscription != null);
|
||||
}
|
||||
|
||||
private void setNotify(final boolean isEnabled) {
|
||||
disposables.add(
|
||||
subscriptionManager
|
||||
.updateNotificationMode(
|
||||
currentInfo.getServiceId(),
|
||||
currentInfo.getUrl(),
|
||||
isEnabled ? NotificationMode.ENABLED : NotificationMode.DISABLED)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a snackbar with the option to enable notifications on new streams for this channel.
|
||||
*/
|
||||
private void showNotifySnackbar() {
|
||||
Snackbar.make(itemsList, R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.get_notified, v -> setNotify(true))
|
||||
.setActionTextColor(Color.YELLOW)
|
||||
.show();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Load and handle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
||||
protected Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
|
||||
return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPage);
|
||||
}
|
||||
|
||||
|
@ -15,6 +15,7 @@ import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfo;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.ktx.ViewUtils;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
@ -22,7 +23,7 @@ import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
|
||||
public class CommentsFragment extends BaseListInfoFragment<CommentsInfo> {
|
||||
public class CommentsFragment extends BaseListInfoFragment<CommentsInfoItem, CommentsInfo> {
|
||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||
|
||||
private TextView emptyStateDesc;
|
||||
@ -67,7 +68,7 @@ public class CommentsFragment extends BaseListInfoFragment<CommentsInfo> {
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
||||
protected Single<ListExtractor.InfoItemsPage<CommentsInfoItem>> loadMoreItemsLogic() {
|
||||
return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage);
|
||||
}
|
||||
|
||||
|
@ -21,6 +21,7 @@ import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.extractor.kiosk.KioskInfo;
|
||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory;
|
||||
import org.schabi.newpipe.extractor.localization.ContentCountry;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.KioskTranslator;
|
||||
@ -53,7 +54,7 @@ import io.reactivex.rxjava3.core.Single;
|
||||
* </p>
|
||||
*/
|
||||
|
||||
public class KioskFragment extends BaseListInfoFragment<KioskInfo> {
|
||||
public class KioskFragment extends BaseListInfoFragment<StreamInfoItem, KioskInfo> {
|
||||
@State
|
||||
String kioskId = "";
|
||||
String kioskTranslatedName;
|
||||
@ -145,7 +146,7 @@ public class KioskFragment extends BaseListInfoFragment<KioskInfo> {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
||||
public Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
|
||||
return ExtractorHelper.getMoreKioskItems(serviceId, url, currentNextPage);
|
||||
}
|
||||
|
||||
|
@ -1,11 +1,10 @@
|
||||
package org.schabi.newpipe.fragments.list.playlist;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
@ -19,6 +18,10 @@ import android.view.ViewGroup;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.google.android.material.shape.CornerFamily;
|
||||
import com.google.android.material.shape.ShapeAppearanceModel;
|
||||
|
||||
import org.reactivestreams.Subscriber;
|
||||
import org.reactivestreams.Subscription;
|
||||
@ -36,24 +39,20 @@ import org.schabi.newpipe.extractor.ServiceList;
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
|
||||
import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.info_list.InfoItemDialog;
|
||||
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
|
||||
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
|
||||
import org.schabi.newpipe.player.MainPlayer.PlayerType;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.StreamDialogEntry;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Supplier;
|
||||
@ -64,7 +63,7 @@ import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||
public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, PlaylistInfo> {
|
||||
|
||||
private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG";
|
||||
|
||||
@ -140,60 +139,22 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void showStreamDialog(final StreamInfoItem item) {
|
||||
protected void showInfoItemDialog(final StreamInfoItem item) {
|
||||
final Context context = getContext();
|
||||
final Activity activity = getActivity();
|
||||
if (context == null || context.getResources() == null || activity == null) {
|
||||
return;
|
||||
try {
|
||||
final InfoItemDialog.Builder dialogBuilder =
|
||||
new InfoItemDialog.Builder(getActivity(), context, this, item);
|
||||
|
||||
dialogBuilder
|
||||
.setAction(
|
||||
StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND,
|
||||
(f, infoItem) -> NavigationHelper.playOnBackgroundPlayer(
|
||||
context, getPlayQueueStartingAt(infoItem), true))
|
||||
.create()
|
||||
.show();
|
||||
} catch (final IllegalArgumentException e) {
|
||||
InfoItemDialog.Builder.reportErrorDuringInitialization(e, item);
|
||||
}
|
||||
|
||||
final ArrayList<StreamDialogEntry> entries = new ArrayList<>();
|
||||
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
||||
entries.add(StreamDialogEntry.enqueue);
|
||||
|
||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
||||
entries.add(StreamDialogEntry.enqueue_next);
|
||||
}
|
||||
}
|
||||
|
||||
if (item.getStreamType() == StreamType.AUDIO_STREAM) {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
} else {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.start_here_on_popup,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
}
|
||||
entries.add(StreamDialogEntry.open_in_browser);
|
||||
if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) {
|
||||
entries.add(StreamDialogEntry.play_with_kodi);
|
||||
}
|
||||
|
||||
// show "mark as watched" only when watch history is enabled
|
||||
if (StreamDialogEntry.shouldAddMarkAsWatched(item.getStreamType(), context)) {
|
||||
entries.add(
|
||||
StreamDialogEntry.mark_as_watched
|
||||
);
|
||||
}
|
||||
if (!isNullOrEmpty(item.getUploaderUrl())) {
|
||||
entries.add(StreamDialogEntry.show_channel_details);
|
||||
}
|
||||
|
||||
StreamDialogEntry.setEnabledEntries(entries);
|
||||
|
||||
StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItem) ->
|
||||
NavigationHelper.playOnBackgroundPlayer(context,
|
||||
getPlayQueueStartingAt(infoItem), true));
|
||||
|
||||
new InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context),
|
||||
(dialog, which) -> StreamDialogEntry.clickOn(which, this, item)).show();
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -249,7 +210,7 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
||||
protected Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
|
||||
return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPage);
|
||||
}
|
||||
|
||||
@ -328,9 +289,14 @@ public class PlaylistFragment extends BaseListInfoFragment<PlaylistInfo> {
|
||||
&& (YoutubeParsingHelper.isYoutubeMixId(result.getId())
|
||||
|| YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) {
|
||||
// this is an auto-generated playlist (e.g. Youtube mix), so a radio is shown
|
||||
headerBinding.uploaderAvatarView.setDisableCircularTransformation(true);
|
||||
headerBinding.uploaderAvatarView.setBorderColor(
|
||||
getResources().getColor(R.color.transparent_background_color));
|
||||
final ShapeAppearanceModel model = ShapeAppearanceModel.builder()
|
||||
.setAllCorners(CornerFamily.ROUNDED, 0f)
|
||||
.build(); // this turns the image back into a square
|
||||
headerBinding.uploaderAvatarView.setShapeAppearanceModel(model);
|
||||
headerBinding.uploaderAvatarView.setStrokeColor(
|
||||
ColorStateList.valueOf(ContextCompat.getColor(
|
||||
requireContext(), R.color.transparent_background_color))
|
||||
);
|
||||
headerBinding.uploaderAvatarView.setImageDrawable(
|
||||
AppCompatResources.getDrawable(requireContext(),
|
||||
R.drawable.ic_radio)
|
||||
|
@ -7,6 +7,7 @@ import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
@ -34,8 +35,10 @@ public class SuggestionListAdapter
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public SuggestionItemHolder onCreateViewHolder(final ViewGroup parent, final int viewType) {
|
||||
public SuggestionItemHolder onCreateViewHolder(@NonNull final ViewGroup parent,
|
||||
final int viewType) {
|
||||
return new SuggestionItemHolder(LayoutInflater.from(context)
|
||||
.inflate(R.layout.item_search_suggestion, parent, false));
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ import androidx.preference.PreferenceManager;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.RelatedItemsHeaderBinding;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
@ -26,7 +27,7 @@ import java.util.function.Supplier;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
|
||||
public class RelatedItemsFragment extends BaseListInfoFragment<RelatedItemInfo>
|
||||
public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, RelatedItemInfo>
|
||||
implements SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
private static final String INFO_KEY = "related_info_key";
|
||||
|
||||
@ -86,7 +87,7 @@ public class RelatedItemsFragment extends BaseListInfoFragment<RelatedItemInfo>
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Single<ListExtractor.InfoItemsPage> loadMoreItemsLogic() {
|
||||
protected Single<ListExtractor.InfoItemsPage<InfoItem>> loadMoreItemsLogic() {
|
||||
return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage);
|
||||
}
|
||||
|
||||
|
@ -1,54 +0,0 @@
|
||||
package org.schabi.newpipe.info_list;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.DialogInterface;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
|
||||
public class InfoItemDialog {
|
||||
private final AlertDialog dialog;
|
||||
|
||||
public InfoItemDialog(@NonNull final Activity activity,
|
||||
@NonNull final StreamInfoItem info,
|
||||
@NonNull final String[] commands,
|
||||
@NonNull final DialogInterface.OnClickListener actions) {
|
||||
this(activity, commands, actions, info.getName(), info.getUploaderName());
|
||||
}
|
||||
|
||||
public InfoItemDialog(@NonNull final Activity activity,
|
||||
@NonNull final String[] commands,
|
||||
@NonNull final DialogInterface.OnClickListener actions,
|
||||
@NonNull final String title,
|
||||
@Nullable final String additionalDetail) {
|
||||
|
||||
final View bannerView = View.inflate(activity, R.layout.dialog_title, null);
|
||||
bannerView.setSelected(true);
|
||||
|
||||
final TextView titleView = bannerView.findViewById(R.id.itemTitleView);
|
||||
titleView.setText(title);
|
||||
|
||||
final TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails);
|
||||
if (additionalDetail != null) {
|
||||
detailsView.setText(additionalDetail);
|
||||
detailsView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
detailsView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
dialog = new AlertDialog.Builder(activity)
|
||||
.setCustomTitle(bannerView)
|
||||
.setItems(commands, actions)
|
||||
.create();
|
||||
}
|
||||
|
||||
public void show() {
|
||||
dialog.show();
|
||||
}
|
||||
}
|
@ -0,0 +1,356 @@
|
||||
package org.schabi.newpipe.info_list.dialog;
|
||||
|
||||
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.App;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Dialog for a {@link StreamInfoItem}.
|
||||
* The dialog's content are actions that can be performed on the {@link StreamInfoItem}.
|
||||
* This dialog is mostly used for longpress context menus.
|
||||
*/
|
||||
public final class InfoItemDialog {
|
||||
private static final String TAG = Build.class.getSimpleName();
|
||||
/**
|
||||
* Ideally, {@link InfoItemDialog} would extend {@link AlertDialog}.
|
||||
* However, extending {@link AlertDialog} requires many additional lines
|
||||
* and brings more complexity to this class, especially the constructor.
|
||||
* To circumvent this, an {@link AlertDialog.Builder} is used in the constructor.
|
||||
* Its result is stored in this class variable to allow access via the {@link #show()} method.
|
||||
*/
|
||||
private final AlertDialog dialog;
|
||||
|
||||
private InfoItemDialog(@NonNull final Activity activity,
|
||||
@NonNull final Fragment fragment,
|
||||
@NonNull final StreamInfoItem info,
|
||||
@NonNull final List<StreamDialogEntry> entries) {
|
||||
|
||||
// Create the dialog's title
|
||||
final View bannerView = View.inflate(activity, R.layout.dialog_title, null);
|
||||
bannerView.setSelected(true);
|
||||
|
||||
final TextView titleView = bannerView.findViewById(R.id.itemTitleView);
|
||||
titleView.setText(info.getName());
|
||||
|
||||
final TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails);
|
||||
if (info.getUploaderName() != null) {
|
||||
detailsView.setText(info.getUploaderName());
|
||||
detailsView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
detailsView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
// Get the entry's descriptions which are displayed in the dialog
|
||||
final String[] items = entries.stream()
|
||||
.map(entry -> entry.getString(activity)).toArray(String[]::new);
|
||||
|
||||
// Call an entry's action / onClick method when the entry is selected.
|
||||
final DialogInterface.OnClickListener action = (d, index) ->
|
||||
entries.get(index).action.onClick(fragment, info);
|
||||
|
||||
dialog = new AlertDialog.Builder(activity)
|
||||
.setCustomTitle(bannerView)
|
||||
.setItems(items, action)
|
||||
.create();
|
||||
|
||||
}
|
||||
|
||||
public void show() {
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Builder to generate a {@link InfoItemDialog} for a {@link StreamInfoItem}.</p>
|
||||
* Use {@link #addEntry(StreamDialogDefaultEntry)}
|
||||
* and {@link #addAllEntries(StreamDialogDefaultEntry...)} to add options to the dialog.
|
||||
* <br>
|
||||
* Custom actions for entries can be set using
|
||||
* {@link #setAction(StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)}.
|
||||
*/
|
||||
public static class Builder {
|
||||
@NonNull private final Activity activity;
|
||||
@NonNull private final Context context;
|
||||
@NonNull private final StreamInfoItem infoItem;
|
||||
@NonNull private final Fragment fragment;
|
||||
@NonNull private final List<StreamDialogEntry> entries = new ArrayList<>();
|
||||
private final boolean addDefaultEntriesAutomatically;
|
||||
|
||||
/**
|
||||
* <p>Create a {@link Builder builder} instance for a {@link StreamInfoItem}
|
||||
* that automatically adds the some default entries
|
||||
* at the top and bottom of the dialog.</p>
|
||||
* The dialog has the following structure:
|
||||
* <pre>
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* | ENQUEUE |
|
||||
* | ENQUEUE_NEXT |
|
||||
* | START_ON_BACKGROUND |
|
||||
* | START_ON_POPUP |
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* | entries added manually with |
|
||||
* | addEntry() and addAllEntries() |
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* | APPEND_PLAYLIST |
|
||||
* | SHARE |
|
||||
* | OPEN_IN_BROWSER |
|
||||
* | PLAY_WITH_KODI |
|
||||
* | MARK_AS_WATCHED |
|
||||
* | SHOW_CHANNEL_DETAILS |
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* </pre>
|
||||
* Please note that some entries are not added depending on the user's preferences,
|
||||
* the item's {@link StreamType} and the current player state.
|
||||
*
|
||||
* @param activity
|
||||
* @param context
|
||||
* @param fragment
|
||||
* @param infoItem the item for this dialog; all entries and their actions work with
|
||||
* this {@link StreamInfoItem}
|
||||
* @throws IllegalArgumentException if <code>activity, context</code>
|
||||
* or resources is <code>null</code>
|
||||
*/
|
||||
public Builder(final Activity activity,
|
||||
final Context context,
|
||||
@NonNull final Fragment fragment,
|
||||
@NonNull final StreamInfoItem infoItem) {
|
||||
this(activity, context, fragment, infoItem, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Create an instance of this {@link Builder} for a {@link StreamInfoItem}.</p>
|
||||
* <p>If {@code addDefaultEntriesAutomatically} is set to {@code true},
|
||||
* some default entries are added to the top and bottom of the dialog.</p>
|
||||
* The dialog has the following structure:
|
||||
* <pre>
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* | ENQUEUE |
|
||||
* | ENQUEUE_NEXT |
|
||||
* | START_ON_BACKGROUND |
|
||||
* | START_ON_POPUP |
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* | entries added manually with |
|
||||
* | addEntry() and addAllEntries() |
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* | APPEND_PLAYLIST |
|
||||
* | SHARE |
|
||||
* | OPEN_IN_BROWSER |
|
||||
* | PLAY_WITH_KODI |
|
||||
* | MARK_AS_WATCHED |
|
||||
* | SHOW_CHANNEL_DETAILS |
|
||||
* + - - - - - - - - - - - - - - - - - - - - - -+
|
||||
* </pre>
|
||||
* Please note that some entries are not added depending on the user's preferences,
|
||||
* the item's {@link StreamType} and the current player state.
|
||||
*
|
||||
* @param activity
|
||||
* @param context
|
||||
* @param fragment
|
||||
* @param infoItem
|
||||
* @param addDefaultEntriesAutomatically
|
||||
* whether default entries added with {@link #addDefaultBeginningEntries()}
|
||||
* and {@link #addDefaultEndEntries()} are added automatically when generating
|
||||
* the {@link InfoItemDialog}.
|
||||
* <br/>
|
||||
* Entries added with {@link #addEntry(StreamDialogDefaultEntry)} and
|
||||
* {@link #addAllEntries(StreamDialogDefaultEntry...)} are added in between.
|
||||
* @throws IllegalArgumentException if <code>activity, context</code>
|
||||
* or resources is <code>null</code>
|
||||
*/
|
||||
public Builder(final Activity activity,
|
||||
final Context context,
|
||||
@NonNull final Fragment fragment,
|
||||
@NonNull final StreamInfoItem infoItem,
|
||||
final boolean addDefaultEntriesAutomatically) {
|
||||
if (activity == null || context == null || context.getResources() == null) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "activity, context or resources is null: activity = "
|
||||
+ activity + ", context = " + context);
|
||||
}
|
||||
throw new IllegalArgumentException("activity, context or resources is null");
|
||||
}
|
||||
this.activity = activity;
|
||||
this.context = context;
|
||||
this.fragment = fragment;
|
||||
this.infoItem = infoItem;
|
||||
this.addDefaultEntriesAutomatically = addDefaultEntriesAutomatically;
|
||||
if (addDefaultEntriesAutomatically) {
|
||||
addDefaultBeginningEntries();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new entry and appends it to the current entry list.
|
||||
* @param entry the entry to add
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder addEntry(@NonNull final StreamDialogDefaultEntry entry) {
|
||||
entries.add(entry.toStreamDialogEntry());
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds new entries. These are appended to the current entry list.
|
||||
* @param newEntries the entries to add
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder addAllEntries(@NonNull final StreamDialogDefaultEntry... newEntries) {
|
||||
Stream.of(newEntries).forEach(this::addEntry);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Change an entries' action that is called when the entry is selected.</p>
|
||||
* <p><strong>Warning:</strong> Only use this method when the entry has been already added.
|
||||
* Changing the action of an entry which has not been added to the Builder yet
|
||||
* does not have an effect.</p>
|
||||
* @param entry the entry to change
|
||||
* @param action the action to perform when the entry is selected
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder setAction(@NonNull final StreamDialogDefaultEntry entry,
|
||||
@NonNull final StreamDialogEntry.StreamDialogEntryAction action) {
|
||||
for (int i = 0; i < entries.size(); i++) {
|
||||
if (entries.get(i).resource == entry.resource) {
|
||||
entries.set(i, new StreamDialogEntry(entry.resource, action));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds {@link StreamDialogDefaultEntry#ENQUEUE} if the player is open and
|
||||
* {@link StreamDialogDefaultEntry#ENQUEUE_NEXT} if there are multiple streams
|
||||
* in the play queue.
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder addEnqueueEntriesIfNeeded() {
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
||||
addEntry(StreamDialogDefaultEntry.ENQUEUE);
|
||||
|
||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
||||
addEntry(StreamDialogDefaultEntry.ENQUEUE_NEXT);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the {@link StreamDialogDefaultEntry#START_HERE_ON_BACKGROUND}.
|
||||
* If the {@link #infoItem} is not a pure audio (live) stream,
|
||||
* {@link StreamDialogDefaultEntry#START_HERE_ON_POPUP} is added, too.
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder addStartHereEntries() {
|
||||
addEntry(StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND);
|
||||
if (infoItem.getStreamType() != StreamType.AUDIO_STREAM
|
||||
&& infoItem.getStreamType() != StreamType.AUDIO_LIVE_STREAM) {
|
||||
addEntry(StreamDialogDefaultEntry.START_HERE_ON_POPUP);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds {@link StreamDialogDefaultEntry#MARK_AS_WATCHED} if the watch history is enabled
|
||||
* and the stream is not a livestream.
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder addMarkAsWatchedEntryIfNeeded() {
|
||||
final boolean isWatchHistoryEnabled = PreferenceManager
|
||||
.getDefaultSharedPreferences(context)
|
||||
.getBoolean(context.getString(R.string.enable_watch_history_key), false);
|
||||
if (isWatchHistoryEnabled
|
||||
&& infoItem.getStreamType() != StreamType.LIVE_STREAM
|
||||
&& infoItem.getStreamType() != StreamType.AUDIO_LIVE_STREAM) {
|
||||
addEntry(StreamDialogDefaultEntry.MARK_AS_WATCHED);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the {@link StreamDialogDefaultEntry#PLAY_WITH_KODI} entry if it is needed.
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder addPlayWithKodiEntryIfNeeded() {
|
||||
if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
|
||||
addEntry(StreamDialogDefaultEntry.PLAY_WITH_KODI);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the entries which are usually at the top of the action list.
|
||||
* <br/>
|
||||
* This method adds the "enqueue" (see {@link #addEnqueueEntriesIfNeeded()})
|
||||
* and "start here" (see {@link #addStartHereEntries()} entries.
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder addDefaultBeginningEntries() {
|
||||
addEnqueueEntriesIfNeeded();
|
||||
addStartHereEntries();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the entries which are usually at the bottom of the action list.
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder addDefaultEndEntries() {
|
||||
addAllEntries(
|
||||
StreamDialogDefaultEntry.APPEND_PLAYLIST,
|
||||
StreamDialogDefaultEntry.SHARE,
|
||||
StreamDialogDefaultEntry.OPEN_IN_BROWSER
|
||||
);
|
||||
addPlayWithKodiEntryIfNeeded();
|
||||
addMarkAsWatchedEntryIfNeeded();
|
||||
addEntry(StreamDialogDefaultEntry.SHOW_CHANNEL_DETAILS);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the {@link InfoItemDialog}.
|
||||
* @return a new instance of {@link InfoItemDialog}
|
||||
*/
|
||||
public InfoItemDialog create() {
|
||||
if (addDefaultEntriesAutomatically) {
|
||||
addDefaultEndEntries();
|
||||
}
|
||||
return new InfoItemDialog(this.activity, this.fragment, this.infoItem, this.entries);
|
||||
}
|
||||
|
||||
public static void reportErrorDuringInitialization(final Throwable throwable,
|
||||
final InfoItem item) {
|
||||
ErrorUtil.showSnackbar(App.getApp().getBaseContext(), new ErrorInfo(
|
||||
throwable,
|
||||
UserAction.OPEN_INFO_ITEM_DIALOG,
|
||||
"none",
|
||||
item.getServiceId()));
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,142 @@
|
||||
package org.schabi.newpipe.info_list.dialog;
|
||||
|
||||
import static org.schabi.newpipe.util.NavigationHelper.openChannelFragment;
|
||||
import static org.schabi.newpipe.util.SparseItemUtil.fetchItemInfoIfSparse;
|
||||
import static org.schabi.newpipe.util.SparseItemUtil.fetchUploaderUrlIfSparse;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.local.dialog.PlaylistAppendDialog;
|
||||
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* This enum provides entries that are accepted
|
||||
* by the {@link InfoItemDialog.Builder}.
|
||||
* </p>
|
||||
* <p>
|
||||
* These entries contain a String {@link #resource} which is displayed in the dialog and
|
||||
* a default {@link #action} that is executed
|
||||
* when the entry is selected (via <code>onClick()</code>).
|
||||
* <br/>
|
||||
* They action can be overridden by using the Builder's
|
||||
* {@link InfoItemDialog.Builder#setAction(
|
||||
* StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)}
|
||||
* method.
|
||||
* </p>
|
||||
*/
|
||||
public enum StreamDialogDefaultEntry {
|
||||
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) ->
|
||||
fetchUploaderUrlIfSparse(fragment.requireContext(), item.getServiceId(), item.getUrl(),
|
||||
item.getUploaderUrl(), url -> openChannelFragment(fragment, item, url))
|
||||
),
|
||||
|
||||
/**
|
||||
* Enqueues the stream automatically to the current PlayerType.
|
||||
*/
|
||||
ENQUEUE(R.string.enqueue_stream, (fragment, item) ->
|
||||
fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue ->
|
||||
NavigationHelper.enqueueOnPlayer(fragment.getContext(), singlePlayQueue))
|
||||
),
|
||||
|
||||
/**
|
||||
* Enqueues the stream automatically to the current PlayerType
|
||||
* after the currently playing stream.
|
||||
*/
|
||||
ENQUEUE_NEXT(R.string.enqueue_next_stream, (fragment, item) ->
|
||||
fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue ->
|
||||
NavigationHelper.enqueueNextOnPlayer(fragment.getContext(), singlePlayQueue))
|
||||
),
|
||||
|
||||
START_HERE_ON_BACKGROUND(R.string.start_here_on_background, (fragment, item) ->
|
||||
fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue ->
|
||||
NavigationHelper.playOnBackgroundPlayer(
|
||||
fragment.getContext(), singlePlayQueue, true))),
|
||||
|
||||
START_HERE_ON_POPUP(R.string.start_here_on_popup, (fragment, item) ->
|
||||
fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue ->
|
||||
NavigationHelper.playOnPopupPlayer(fragment.getContext(), singlePlayQueue, true))),
|
||||
|
||||
SET_AS_PLAYLIST_THUMBNAIL(R.string.set_as_playlist_thumbnail, (fragment, item) -> {
|
||||
throw new UnsupportedOperationException("This needs to be implemented manually "
|
||||
+ "by using InfoItemDialog.Builder.setAction()");
|
||||
}),
|
||||
|
||||
DELETE(R.string.delete, (fragment, item) -> {
|
||||
throw new UnsupportedOperationException("This needs to be implemented manually "
|
||||
+ "by using InfoItemDialog.Builder.setAction()");
|
||||
}),
|
||||
|
||||
/**
|
||||
* Opens a {@link PlaylistDialog} to either append the stream to a playlist
|
||||
* or create a new playlist if there are no local playlists.
|
||||
*/
|
||||
APPEND_PLAYLIST(R.string.add_to_playlist, (fragment, item) ->
|
||||
PlaylistDialog.createCorrespondingDialog(
|
||||
fragment.getContext(),
|
||||
Collections.singletonList(new StreamEntity(item)),
|
||||
dialog -> dialog.show(
|
||||
fragment.getParentFragmentManager(),
|
||||
"StreamDialogEntry@"
|
||||
+ (dialog instanceof PlaylistAppendDialog ? "append" : "create")
|
||||
+ "_playlist"
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
PLAY_WITH_KODI(R.string.play_with_kodi_title, (fragment, item) -> {
|
||||
final Uri videoUrl = Uri.parse(item.getUrl());
|
||||
try {
|
||||
NavigationHelper.playWithKore(fragment.requireContext(), videoUrl);
|
||||
} catch (final Exception e) {
|
||||
KoreUtils.showInstallKoreDialog(fragment.requireActivity());
|
||||
}
|
||||
}),
|
||||
|
||||
SHARE(R.string.share, (fragment, item) ->
|
||||
ShareUtils.shareText(fragment.requireContext(), item.getName(), item.getUrl(),
|
||||
item.getThumbnailUrl())),
|
||||
|
||||
OPEN_IN_BROWSER(R.string.open_in_browser, (fragment, item) ->
|
||||
ShareUtils.openUrlInBrowser(fragment.requireContext(), item.getUrl())),
|
||||
|
||||
|
||||
MARK_AS_WATCHED(R.string.mark_as_watched, (fragment, item) ->
|
||||
new HistoryRecordManager(fragment.getContext())
|
||||
.markAsWatched(item)
|
||||
.onErrorComplete()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe()
|
||||
);
|
||||
|
||||
|
||||
@StringRes
|
||||
public final int resource;
|
||||
@NonNull
|
||||
public final StreamDialogEntry.StreamDialogEntryAction action;
|
||||
|
||||
StreamDialogDefaultEntry(@StringRes final int resource,
|
||||
@NonNull final StreamDialogEntry.StreamDialogEntryAction action) {
|
||||
this.resource = resource;
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public StreamDialogEntry toStreamDialogEntry() {
|
||||
return new StreamDialogEntry(resource, action);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
package org.schabi.newpipe.info_list.dialog;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
|
||||
public class StreamDialogEntry {
|
||||
|
||||
@StringRes
|
||||
public final int resource;
|
||||
@NonNull
|
||||
public final StreamDialogEntryAction action;
|
||||
|
||||
public StreamDialogEntry(@StringRes final int resource,
|
||||
@NonNull final StreamDialogEntryAction action) {
|
||||
this.resource = resource;
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
public String getString(@NonNull final Context context) {
|
||||
return context.getString(resource);
|
||||
}
|
||||
|
||||
public interface StreamDialogEntryAction {
|
||||
void onClick(Fragment fragment, StreamInfoItem infoItem);
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
package org.schabi.newpipe.info_list.holder;
|
||||
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
@ -11,10 +12,8 @@ import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
|
||||
import de.hdodenhof.circleimageview.CircleImageView;
|
||||
|
||||
public class ChannelMiniInfoItemHolder extends InfoItemHolder {
|
||||
public final CircleImageView itemThumbnailView;
|
||||
public final ImageView itemThumbnailView;
|
||||
public final TextView itemTitleView;
|
||||
private final TextView itemAdditionalDetailView;
|
||||
|
||||
|
@ -7,6 +7,7 @@ import android.text.util.Linkify;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
@ -28,8 +29,6 @@ import org.schabi.newpipe.util.PicassoHelper;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
|
||||
import de.hdodenhof.circleimageview.CircleImageView;
|
||||
|
||||
public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
||||
private static final String TAG = "CommentsMiniIIHolder";
|
||||
|
||||
@ -40,7 +39,7 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
||||
private final int commentVerticalPadding;
|
||||
|
||||
private final RelativeLayout itemRoot;
|
||||
public final CircleImageView itemThumbnailView;
|
||||
public final ImageView itemThumbnailView;
|
||||
private final TextView itemContentView;
|
||||
private final TextView itemLikesCountView;
|
||||
private final TextView itemPublishedTime;
|
||||
|
@ -228,6 +228,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||
return count;
|
||||
}
|
||||
|
||||
@SuppressWarnings("FinalParameters")
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
if (DEBUG) {
|
||||
@ -300,6 +301,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("FinalParameters")
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int position) {
|
||||
if (DEBUG) {
|
||||
|
@ -33,8 +33,16 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
|
||||
|
||||
private final CompositeDisposable playlistDisposables = new CompositeDisposable();
|
||||
|
||||
public PlaylistAppendDialog(final List<StreamEntity> streamEntities) {
|
||||
super(streamEntities);
|
||||
/**
|
||||
* Create a new instance of {@link PlaylistAppendDialog}.
|
||||
*
|
||||
* @param streamEntities a list of {@link StreamEntity} to be added to playlists
|
||||
* @return a new instance of {@link PlaylistAppendDialog}
|
||||
*/
|
||||
public static PlaylistAppendDialog newInstance(final List<StreamEntity> streamEntities) {
|
||||
final PlaylistAppendDialog dialog = new PlaylistAppendDialog();
|
||||
dialog.setStreamEntities(streamEntities);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
@ -103,13 +111,14 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
|
||||
// Helper
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
/** Display create playlist dialog. */
|
||||
public void openCreatePlaylistDialog() {
|
||||
if (getStreamEntities() == null || !isAdded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final PlaylistCreationDialog playlistCreationDialog =
|
||||
new PlaylistCreationDialog(getStreamEntities());
|
||||
PlaylistCreationDialog.newInstance(getStreamEntities());
|
||||
// Move the dismissListener to the new dialog.
|
||||
playlistCreationDialog.setOnDismissListener(this.getOnDismissListener());
|
||||
this.setOnDismissListener(null);
|
||||
|
@ -21,8 +21,17 @@ import java.util.List;
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
|
||||
public final class PlaylistCreationDialog extends PlaylistDialog {
|
||||
public PlaylistCreationDialog(final List<StreamEntity> streamEntities) {
|
||||
super(streamEntities);
|
||||
|
||||
/**
|
||||
* Create a new instance of {@link PlaylistCreationDialog}.
|
||||
*
|
||||
* @param streamEntities a list of {@link StreamEntity} to be added to playlists
|
||||
* @return a new instance of {@link PlaylistCreationDialog}
|
||||
*/
|
||||
public static PlaylistCreationDialog newInstance(final List<StreamEntity> streamEntities) {
|
||||
final PlaylistCreationDialog dialog = new PlaylistCreationDialog();
|
||||
dialog.setStreamEntities(streamEntities);
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
|
@ -31,10 +31,6 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
|
||||
|
||||
private org.schabi.newpipe.util.SavedState savedState;
|
||||
|
||||
public PlaylistDialog(final List<StreamEntity> streamEntities) {
|
||||
this.streamEntities = streamEntities;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// LifeCycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@ -97,7 +93,7 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(final Bundle outState) {
|
||||
public void onSaveInstanceState(@NonNull final Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
if (getActivity() != null) {
|
||||
savedState = StateSaver.tryToSave(getActivity().isChangingConfigurations(),
|
||||
@ -120,6 +116,10 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
|
||||
this.onDismissListener = onDismissListener;
|
||||
}
|
||||
|
||||
protected void setStreamEntities(final List<StreamEntity> streamEntities) {
|
||||
this.streamEntities = streamEntities;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Dialog creation
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@ -143,8 +143,8 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(hasPlaylists ->
|
||||
onExec.accept(hasPlaylists
|
||||
? new PlaylistAppendDialog(streamEntities)
|
||||
: new PlaylistCreationDialog(streamEntities))
|
||||
? PlaylistAppendDialog.newInstance(streamEntities)
|
||||
: PlaylistCreationDialog.newInstance(streamEntities))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
||||
import org.schabi.newpipe.database.stream.StreamWithState
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||
@ -57,6 +58,11 @@ class FeedDatabaseManager(context: Context) {
|
||||
|
||||
fun outdatedSubscriptions(outdatedThreshold: OffsetDateTime) = feedTable.getAllOutdated(outdatedThreshold)
|
||||
|
||||
fun outdatedSubscriptionsWithNotificationMode(
|
||||
outdatedThreshold: OffsetDateTime,
|
||||
@NotificationMode notificationMode: Int
|
||||
) = feedTable.getOutdatedWithNotificationMode(outdatedThreshold, notificationMode)
|
||||
|
||||
fun notLoadedCount(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable<Long> {
|
||||
return when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> feedTable.notLoadedCount()
|
||||
@ -72,6 +78,10 @@ class FeedDatabaseManager(context: Context) {
|
||||
fun markAsOutdated(subscriptionId: Long) = feedTable
|
||||
.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null))
|
||||
|
||||
fun doesStreamExist(stream: StreamInfoItem): Boolean {
|
||||
return streamTable.exists(stream.serviceId, stream.url)
|
||||
}
|
||||
|
||||
fun upsertAll(
|
||||
subscriptionId: Long,
|
||||
items: List<StreamInfoItem>,
|
||||
|
@ -50,7 +50,6 @@ import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.xwray.groupie.GroupieAdapter
|
||||
import com.xwray.groupie.Item
|
||||
import com.xwray.groupie.OnAsyncUpdateListener
|
||||
import com.xwray.groupie.OnItemClickListener
|
||||
import com.xwray.groupie.OnItemLongClickListener
|
||||
import icepick.State
|
||||
@ -68,25 +67,21 @@ import org.schabi.newpipe.error.UserAction
|
||||
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
|
||||
import org.schabi.newpipe.fragments.BaseStateFragment
|
||||
import org.schabi.newpipe.info_list.InfoItemDialog
|
||||
import org.schabi.newpipe.info_list.dialog.InfoItemDialog
|
||||
import org.schabi.newpipe.ktx.animate
|
||||
import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling
|
||||
import org.schabi.newpipe.ktx.slideUp
|
||||
import org.schabi.newpipe.local.feed.item.StreamItem
|
||||
import org.schabi.newpipe.local.feed.service.FeedLoadService
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder
|
||||
import org.schabi.newpipe.util.DeviceUtils
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.NavigationHelper
|
||||
import org.schabi.newpipe.util.StreamDialogEntry
|
||||
import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams
|
||||
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.ArrayList
|
||||
import java.util.function.Consumer
|
||||
|
||||
class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
@ -143,7 +138,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
val factory = FeedViewModel.Factory(requireContext(), groupId)
|
||||
viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java)
|
||||
showPlayedItems = viewModel.getShowPlayedItemsFromPreferences()
|
||||
viewModel.stateLiveData.observe(viewLifecycleOwner, { it?.let(::handleResult) })
|
||||
viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) }
|
||||
|
||||
groupAdapter = GroupieAdapter().apply {
|
||||
setOnItemClickListener(listenerStreamItem)
|
||||
@ -356,53 +351,12 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
feedBinding.loadingProgressBar.max = progressState.maxProgress
|
||||
}
|
||||
|
||||
private fun showStreamDialog(item: StreamInfoItem) {
|
||||
private fun showInfoItemDialog(item: StreamInfoItem) {
|
||||
val context = context
|
||||
val activity: Activity? = getActivity()
|
||||
if (context == null || context.resources == null || activity == null) return
|
||||
|
||||
val entries = ArrayList<StreamDialogEntry>()
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady) {
|
||||
entries.add(StreamDialogEntry.enqueue)
|
||||
|
||||
if (PlayerHolder.getInstance().queueSize > 1) {
|
||||
entries.add(StreamDialogEntry.enqueue_next)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.streamType == StreamType.AUDIO_STREAM) {
|
||||
entries.addAll(
|
||||
listOf(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share,
|
||||
StreamDialogEntry.open_in_browser
|
||||
)
|
||||
)
|
||||
} else {
|
||||
entries.addAll(
|
||||
listOf(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.start_here_on_popup,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share,
|
||||
StreamDialogEntry.open_in_browser
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// show "mark as watched" only when watch history is enabled
|
||||
if (StreamDialogEntry.shouldAddMarkAsWatched(item.streamType, context)) {
|
||||
entries.add(
|
||||
StreamDialogEntry.mark_as_watched
|
||||
)
|
||||
}
|
||||
entries.add(StreamDialogEntry.show_channel_details)
|
||||
|
||||
StreamDialogEntry.setEnabledEntries(entries)
|
||||
InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context)) { _, which ->
|
||||
StreamDialogEntry.clickOn(which, this, item)
|
||||
}.show()
|
||||
InfoItemDialog.Builder(activity, context, this, item).create().show()
|
||||
}
|
||||
|
||||
private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener {
|
||||
@ -418,7 +372,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
|
||||
override fun onItemLongClick(item: Item<*>, view: View): Boolean {
|
||||
if (item is StreamItem && !isRefreshing) {
|
||||
showStreamDialog(item.streamWithState.stream.toStreamInfoItem())
|
||||
showInfoItemDialog(item.streamWithState.stream.toStreamInfoItem())
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@ -438,14 +392,11 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
// This need to be saved in a variable as the update occurs async
|
||||
val oldOldestSubscriptionUpdate = oldestSubscriptionUpdate
|
||||
|
||||
groupAdapter.updateAsync(
|
||||
loadedState.items, false,
|
||||
OnAsyncUpdateListener {
|
||||
oldOldestSubscriptionUpdate?.run {
|
||||
highlightNewItemsAfter(oldOldestSubscriptionUpdate)
|
||||
}
|
||||
groupAdapter.updateAsync(loadedState.items, false) {
|
||||
oldOldestSubscriptionUpdate?.run {
|
||||
highlightNewItemsAfter(oldOldestSubscriptionUpdate)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
listState?.run {
|
||||
feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState)
|
||||
@ -497,8 +448,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
}.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
subscriptionEntity ->
|
||||
{ subscriptionEntity ->
|
||||
handleFeedNotAvailable(
|
||||
subscriptionEntity,
|
||||
t.cause,
|
||||
|
@ -56,7 +56,7 @@ class FeedViewModel(
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(Schedulers.io())
|
||||
.map { (event, showPlayedItems, notLoadedCount, oldestUpdate) ->
|
||||
var streamItems = if (event is SuccessResultEvent || event is IdleEvent)
|
||||
val streamItems = if (event is SuccessResultEvent || event is IdleEvent)
|
||||
feedDatabaseManager
|
||||
.getStreams(groupId, showPlayedItems)
|
||||
.blockingGet(arrayListOf())
|
||||
|
@ -0,0 +1,145 @@
|
||||
package org.schabi.newpipe.local.feed.notifications
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.service.FeedUpdateInfo
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.NavigationHelper
|
||||
import org.schabi.newpipe.util.PicassoHelper
|
||||
|
||||
/**
|
||||
* Helper for everything related to show notifications about new streams to the user.
|
||||
*/
|
||||
class NotificationHelper(val context: Context) {
|
||||
|
||||
private val manager = context.getSystemService(
|
||||
Context.NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
|
||||
/**
|
||||
* Show a notification about new streams from a single channel.
|
||||
* Opening the notification will open the corresponding channel page.
|
||||
*/
|
||||
fun displayNewStreamsNotification(data: FeedUpdateInfo) {
|
||||
val newStreams: List<StreamInfoItem> = data.newStreams
|
||||
val summary = context.resources.getQuantityString(
|
||||
R.plurals.new_streams, newStreams.size, newStreams.size
|
||||
)
|
||||
val builder = NotificationCompat.Builder(
|
||||
context,
|
||||
context.getString(R.string.streams_notification_channel_id)
|
||||
)
|
||||
.setContentTitle(Localization.concatenateStrings(data.name, summary))
|
||||
.setContentText(
|
||||
data.listInfo.relatedItems.joinToString(
|
||||
context.getString(R.string.enumeration_comma)
|
||||
) { x -> x.name }
|
||||
)
|
||||
.setNumber(newStreams.size)
|
||||
.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setColor(ContextCompat.getColor(context, R.color.ic_launcher_background))
|
||||
.setColorized(true)
|
||||
.setAutoCancel(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
|
||||
|
||||
// Build style
|
||||
val style = NotificationCompat.InboxStyle()
|
||||
newStreams.forEach { style.addLine(it.name) }
|
||||
style.setSummaryText(summary)
|
||||
style.setBigContentTitle(data.name)
|
||||
builder.setStyle(style)
|
||||
|
||||
// open the channel page when clicking on the notification
|
||||
builder.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
data.pseudoId,
|
||||
NavigationHelper
|
||||
.getChannelIntent(context, data.listInfo.serviceId, data.listInfo.url)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
else
|
||||
0
|
||||
)
|
||||
)
|
||||
|
||||
PicassoHelper.loadNotificationIcon(data.avatarUrl) { bitmap ->
|
||||
bitmap?.let { builder.setLargeIcon(it) } // set only if != null
|
||||
manager.notify(data.pseudoId, builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Check whether notifications are enabled on the device.
|
||||
* Users can disable them via the system settings for a single app.
|
||||
* If this is the case, the app cannot create any notifications
|
||||
* and display them to the user.
|
||||
* <br>
|
||||
* On Android 26 and above, notification channels are used by NewPipe.
|
||||
* These can be configured by the user, too.
|
||||
* The notification channel for new streams is also checked by this method.
|
||||
*
|
||||
* @param context Context
|
||||
* @return <code>true</code> if notifications are allowed and can be displayed;
|
||||
* <code>false</code> otherwise
|
||||
*/
|
||||
fun areNotificationsEnabledOnDevice(context: Context): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channelId = context.getString(R.string.streams_notification_channel_id)
|
||||
val manager = context.getSystemService(
|
||||
Context.NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
val enabled = manager.areNotificationsEnabled()
|
||||
val channel = manager.getNotificationChannel(channelId)
|
||||
val importance = channel?.importance
|
||||
enabled && channel != null && importance != NotificationManager.IMPORTANCE_NONE
|
||||
} else {
|
||||
NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user enabled the notifications for new streams in the app settings.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun areNewStreamsNotificationsEnabled(context: Context): Boolean {
|
||||
return (
|
||||
PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getBoolean(context.getString(R.string.enable_streams_notifications), false) &&
|
||||
areNotificationsEnabledOnDevice(context)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the system's notification settings for NewPipe on Android Oreo (API 26) and later.
|
||||
* Open the system's app settings for NewPipe on previous Android versions.
|
||||
*/
|
||||
fun openNewPipeSystemNotificationSettings(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
} else {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
intent.data = Uri.parse("package:" + context.packageName)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,170 @@
|
||||
package org.schabi.newpipe.local.feed.notifications
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.rxjava3.RxWorker
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.schabi.newpipe.App
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.error.ErrorInfo
|
||||
import org.schabi.newpipe.error.ErrorUtil
|
||||
import org.schabi.newpipe.error.UserAction
|
||||
import org.schabi.newpipe.local.feed.service.FeedLoadManager
|
||||
import org.schabi.newpipe.local.feed.service.FeedLoadService
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/*
|
||||
* Worker which checks for new streams of subscribed channels
|
||||
* in intervals which can be set by the user in the settings.
|
||||
*/
|
||||
class NotificationWorker(
|
||||
appContext: Context,
|
||||
workerParams: WorkerParameters,
|
||||
) : RxWorker(appContext, workerParams) {
|
||||
|
||||
private val notificationHelper by lazy {
|
||||
NotificationHelper(appContext)
|
||||
}
|
||||
private val feedLoadManager = FeedLoadManager(appContext)
|
||||
|
||||
override fun createWork(): Single<Result> = if (areNotificationsEnabled(applicationContext)) {
|
||||
feedLoadManager.startLoading(
|
||||
ignoreOutdatedThreshold = true,
|
||||
groupId = FeedLoadManager.GROUP_NOTIFICATION_ENABLED
|
||||
)
|
||||
.doOnSubscribe { showLoadingFeedForegroundNotification() }
|
||||
.map { feed ->
|
||||
// filter out feedUpdateInfo items (i.e. channels) with nothing new
|
||||
feed.mapNotNull {
|
||||
it.value?.takeIf { feedUpdateInfo ->
|
||||
feedUpdateInfo.newStreams.isNotEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
.observeOn(AndroidSchedulers.mainThread()) // Picasso requires calls from main thread
|
||||
.map { feedUpdateInfoList ->
|
||||
// display notifications for each feedUpdateInfo (i.e. channel)
|
||||
feedUpdateInfoList.forEach { feedUpdateInfo ->
|
||||
notificationHelper.displayNewStreamsNotification(feedUpdateInfo)
|
||||
}
|
||||
return@map Result.success()
|
||||
}
|
||||
.doOnError { throwable ->
|
||||
Log.e(TAG, "Error while displaying streams notifications", throwable)
|
||||
ErrorUtil.createNotification(
|
||||
applicationContext,
|
||||
ErrorInfo(throwable, UserAction.NEW_STREAMS_NOTIFICATIONS, "main worker")
|
||||
)
|
||||
}
|
||||
.onErrorReturnItem(Result.failure())
|
||||
} else {
|
||||
// the user can disable streams notifications in the device's app settings
|
||||
Single.just(Result.success())
|
||||
}
|
||||
|
||||
private fun showLoadingFeedForegroundNotification() {
|
||||
val notification = NotificationCompat.Builder(
|
||||
applicationContext,
|
||||
applicationContext.getString(R.string.notification_channel_id)
|
||||
).setOngoing(true)
|
||||
.setProgress(-1, -1, true)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setContentTitle(applicationContext.getString(R.string.feed_notification_loading))
|
||||
.build()
|
||||
setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification))
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val TAG = NotificationWorker::class.java.simpleName
|
||||
private const val WORK_TAG = App.PACKAGE_NAME + "_streams_notifications"
|
||||
|
||||
private fun areNotificationsEnabled(context: Context) =
|
||||
NotificationHelper.areNewStreamsNotificationsEnabled(context) &&
|
||||
NotificationHelper.areNotificationsEnabledOnDevice(context)
|
||||
|
||||
/**
|
||||
* Schedules a task for the [NotificationWorker]
|
||||
* if the (device and in-app) notifications are enabled,
|
||||
* otherwise [cancel]s all scheduled tasks.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun initialize(context: Context) {
|
||||
if (areNotificationsEnabled(context)) {
|
||||
schedule(context)
|
||||
} else {
|
||||
cancel(context)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param context the context to use
|
||||
* @param options configuration options for the scheduler
|
||||
* @param force Force the scheduler to use the new options
|
||||
* by replacing the previously used worker.
|
||||
*/
|
||||
fun schedule(context: Context, options: ScheduleOptions, force: Boolean = false) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(
|
||||
if (options.isRequireNonMeteredNetwork) {
|
||||
NetworkType.UNMETERED
|
||||
} else {
|
||||
NetworkType.CONNECTED
|
||||
}
|
||||
).build()
|
||||
|
||||
val request = PeriodicWorkRequest.Builder(
|
||||
NotificationWorker::class.java,
|
||||
options.interval,
|
||||
TimeUnit.MILLISECONDS
|
||||
).setConstraints(constraints)
|
||||
.addTag(WORK_TAG)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniquePeriodicWork(
|
||||
WORK_TAG,
|
||||
if (force) {
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
} else {
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
},
|
||||
request
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun schedule(context: Context) = schedule(context, ScheduleOptions.from(context))
|
||||
|
||||
/**
|
||||
* Check for new streams immediately
|
||||
*/
|
||||
@JvmStatic
|
||||
fun runNow(context: Context) {
|
||||
val request = OneTimeWorkRequestBuilder<NotificationWorker>()
|
||||
.addTag(WORK_TAG)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueue(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels all current work related to the [NotificationWorker].
|
||||
*/
|
||||
@JvmStatic
|
||||
fun cancel(context: Context) {
|
||||
WorkManager.getInstance(context).cancelAllWorkByTag(WORK_TAG)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
package org.schabi.newpipe.local.feed.notifications
|
||||
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
import org.schabi.newpipe.R
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Information for the Scheduler which checks for new streams.
|
||||
* See [NotificationWorker]
|
||||
*/
|
||||
data class ScheduleOptions(
|
||||
val interval: Long,
|
||||
val isRequireNonMeteredNetwork: Boolean
|
||||
) {
|
||||
|
||||
companion object {
|
||||
|
||||
fun from(context: Context): ScheduleOptions {
|
||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
return ScheduleOptions(
|
||||
interval = TimeUnit.SECONDS.toMillis(
|
||||
preferences.getString(
|
||||
context.getString(R.string.streams_notifications_interval_key),
|
||||
null
|
||||
)?.toLongOrNull() ?: context.getString(
|
||||
R.string.streams_notifications_interval_default
|
||||
).toLong()
|
||||
),
|
||||
isRequireNonMeteredNetwork = preferences.getString(
|
||||
context.getString(R.string.streams_notifications_network_key),
|
||||
context.getString(R.string.streams_notifications_network_default)
|
||||
) == context.getString(R.string.streams_notifications_network_wifi)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,270 @@
|
||||
package org.schabi.newpipe.local.feed.service
|
||||
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Notification
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.functions.Consumer
|
||||
import io.reactivex.rxjava3.processors.PublishProcessor
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.extractor.ListInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class FeedLoadManager(private val context: Context) {
|
||||
|
||||
private val subscriptionManager = SubscriptionManager(context)
|
||||
private val feedDatabaseManager = FeedDatabaseManager(context)
|
||||
|
||||
private val notificationUpdater = PublishProcessor.create<String>()
|
||||
private val currentProgress = AtomicInteger(-1)
|
||||
private val maxProgress = AtomicInteger(-1)
|
||||
private val cancelSignal = AtomicBoolean()
|
||||
private val feedResultsHolder = FeedResultsHolder()
|
||||
|
||||
val notification: Flowable<FeedLoadState> = notificationUpdater.map { description ->
|
||||
FeedLoadState(description, maxProgress.get(), currentProgress.get())
|
||||
}
|
||||
|
||||
/**
|
||||
* Start checking for new streams of a subscription group.
|
||||
* @param groupId The ID of the subscription group to load. When using
|
||||
* [FeedGroupEntity.GROUP_ALL_ID], all subscriptions are loaded. When using
|
||||
* [GROUP_NOTIFICATION_ENABLED], only subscriptions with enabled notifications for new streams
|
||||
* are loaded. Using an id of a group created by the user results in that specific group to be
|
||||
* loaded.
|
||||
* @param ignoreOutdatedThreshold When `false`, only subscriptions which have not been updated
|
||||
* within the `feed_update_threshold` are checked for updates. This threshold can be set by
|
||||
* the user in the app settings. When `true`, all subscriptions are checked for new streams.
|
||||
*/
|
||||
fun startLoading(
|
||||
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
|
||||
ignoreOutdatedThreshold: Boolean = false,
|
||||
): Single<List<Notification<FeedUpdateInfo>>> {
|
||||
val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val useFeedExtractor = defaultSharedPreferences.getBoolean(
|
||||
context.getString(R.string.feed_use_dedicated_fetch_method_key),
|
||||
false
|
||||
)
|
||||
|
||||
val outdatedThreshold = if (ignoreOutdatedThreshold) {
|
||||
OffsetDateTime.now(ZoneOffset.UTC)
|
||||
} else {
|
||||
val thresholdOutdatedSeconds = (
|
||||
defaultSharedPreferences.getString(
|
||||
context.getString(R.string.feed_update_threshold_key),
|
||||
context.getString(R.string.feed_update_threshold_default_value)
|
||||
) ?: context.getString(R.string.feed_update_threshold_default_value)
|
||||
).toInt()
|
||||
OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong())
|
||||
}
|
||||
|
||||
/**
|
||||
* subscriptions which have not been updated within the feed updated threshold
|
||||
*/
|
||||
val outdatedSubscriptions = when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
|
||||
GROUP_NOTIFICATION_ENABLED -> feedDatabaseManager.outdatedSubscriptionsWithNotificationMode(
|
||||
outdatedThreshold, NotificationMode.ENABLED
|
||||
)
|
||||
else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
|
||||
}
|
||||
|
||||
return outdatedSubscriptions
|
||||
.take(1)
|
||||
.doOnNext {
|
||||
currentProgress.set(0)
|
||||
maxProgress.set(it.size)
|
||||
}
|
||||
.filter { it.isNotEmpty() }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext {
|
||||
notificationUpdater.onNext("")
|
||||
broadcastProgress()
|
||||
}
|
||||
.observeOn(Schedulers.io())
|
||||
.flatMap { Flowable.fromIterable(it) }
|
||||
.takeWhile { !cancelSignal.get() }
|
||||
.parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2)
|
||||
.runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
|
||||
.filter { !cancelSignal.get() }
|
||||
.map { subscriptionEntity ->
|
||||
var error: Throwable? = null
|
||||
try {
|
||||
// check for and load new streams
|
||||
// either by using the dedicated feed method or by getting the channel info
|
||||
val listInfo = if (useFeedExtractor) {
|
||||
ExtractorHelper
|
||||
.getFeedInfoFallbackToChannelInfo(
|
||||
subscriptionEntity.serviceId,
|
||||
subscriptionEntity.url
|
||||
)
|
||||
.onErrorReturn {
|
||||
error = it // store error, otherwise wrapped into RuntimeException
|
||||
throw it
|
||||
}
|
||||
.blockingGet()
|
||||
} else {
|
||||
ExtractorHelper
|
||||
.getChannelInfo(
|
||||
subscriptionEntity.serviceId,
|
||||
subscriptionEntity.url,
|
||||
true
|
||||
)
|
||||
.onErrorReturn {
|
||||
error = it // store error, otherwise wrapped into RuntimeException
|
||||
throw it
|
||||
}
|
||||
.blockingGet()
|
||||
} as ListInfo<StreamInfoItem>
|
||||
|
||||
return@map Notification.createOnNext(
|
||||
FeedUpdateInfo(
|
||||
subscriptionEntity,
|
||||
listInfo
|
||||
)
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
if (error == null) {
|
||||
// do this to prevent blockingGet() from wrapping into RuntimeException
|
||||
error = e
|
||||
}
|
||||
|
||||
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
|
||||
val wrapper =
|
||||
FeedLoadService.RequestException(subscriptionEntity.uid, request, error!!)
|
||||
return@map Notification.createOnError<FeedUpdateInfo>(wrapper)
|
||||
}
|
||||
}
|
||||
.sequential()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext(NotificationConsumer())
|
||||
.observeOn(Schedulers.io())
|
||||
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
||||
.doOnNext(DatabaseConsumer())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.toList()
|
||||
.flatMap { x -> postProcessFeed().toSingleDefault(x.flatten()) }
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
cancelSignal.set(true)
|
||||
}
|
||||
|
||||
private fun broadcastProgress() {
|
||||
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(currentProgress.get(), maxProgress.get()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the feed and the stream tables small
|
||||
* to reduce loading times when trying to display the feed.
|
||||
* <br>
|
||||
* Remove streams from the feed which are older than [FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE].
|
||||
* Remove streams from the database which are not linked / used by any table.
|
||||
*/
|
||||
private fun postProcessFeed() = Completable.fromRunnable {
|
||||
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message))
|
||||
feedDatabaseManager.removeOrphansOrOlderStreams()
|
||||
|
||||
FeedEventManager.postEvent(FeedEventManager.Event.SuccessResultEvent(feedResultsHolder.itemsErrors))
|
||||
}.doOnSubscribe {
|
||||
currentProgress.set(-1)
|
||||
maxProgress.set(-1)
|
||||
|
||||
notificationUpdater.onNext(context.getString(R.string.feed_processing_message))
|
||||
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message))
|
||||
}.subscribeOn(Schedulers.io())
|
||||
|
||||
private inner class NotificationConsumer : Consumer<Notification<FeedUpdateInfo>> {
|
||||
override fun accept(item: Notification<FeedUpdateInfo>) {
|
||||
currentProgress.incrementAndGet()
|
||||
notificationUpdater.onNext(item.value?.name.orEmpty())
|
||||
|
||||
broadcastProgress()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class DatabaseConsumer : Consumer<List<Notification<FeedUpdateInfo>>> {
|
||||
|
||||
override fun accept(list: List<Notification<FeedUpdateInfo>>) {
|
||||
feedDatabaseManager.database().runInTransaction {
|
||||
for (notification in list) {
|
||||
when {
|
||||
notification.isOnNext -> {
|
||||
val subscriptionId = notification.value!!.uid
|
||||
val info = notification.value!!.listInfo
|
||||
|
||||
notification.value!!.newStreams = filterNewStreams(
|
||||
notification.value!!.listInfo.relatedItems
|
||||
)
|
||||
|
||||
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
|
||||
subscriptionManager.updateFromInfo(subscriptionId, info)
|
||||
|
||||
if (info.errors.isNotEmpty()) {
|
||||
feedResultsHolder.addErrors(
|
||||
FeedLoadService.RequestException.wrapList(
|
||||
subscriptionId,
|
||||
info
|
||||
)
|
||||
)
|
||||
feedDatabaseManager.markAsOutdated(subscriptionId)
|
||||
}
|
||||
}
|
||||
notification.isOnError -> {
|
||||
val error = notification.error
|
||||
feedResultsHolder.addError(error!!)
|
||||
|
||||
if (error is FeedLoadService.RequestException) {
|
||||
feedDatabaseManager.markAsOutdated(error.subscriptionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterNewStreams(list: List<StreamInfoItem>): List<StreamInfoItem> {
|
||||
return list.filter {
|
||||
!feedDatabaseManager.doesStreamExist(it) &&
|
||||
it.uploadDate != null &&
|
||||
// Streams older than this date are automatically removed from the feed.
|
||||
// Therefore, streams which are not in the database,
|
||||
// but older than this date, are considered old.
|
||||
it.uploadDate!!.offsetDateTime().isAfter(
|
||||
FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Constant used to check for updates of subscriptions with [NotificationMode.ENABLED].
|
||||
*/
|
||||
const val GROUP_NOTIFICATION_ENABLED = -2L
|
||||
|
||||
/**
|
||||
* How many extractions will be running in parallel.
|
||||
*/
|
||||
private const val PARALLEL_EXTRACTIONS = 6
|
||||
|
||||
/**
|
||||
* Number of items to buffer to mass-insert in the database.
|
||||
*/
|
||||
private const val BUFFER_COUNT_BEFORE_INSERT = 20
|
||||
}
|
||||
}
|
@ -31,41 +31,24 @@ import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.preference.PreferenceManager
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Notification
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.functions.Consumer
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.functions.Function
|
||||
import io.reactivex.rxjava3.processors.PublishProcessor
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.reactivestreams.Subscriber
|
||||
import org.reactivestreams.Subscription
|
||||
import org.schabi.newpipe.App
|
||||
import org.schabi.newpipe.MainActivity.DEBUG
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.extractor.ListInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ProgressEvent
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.SuccessResultEvent
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class FeedLoadService : Service() {
|
||||
companion object {
|
||||
private val TAG = FeedLoadService::class.java.simpleName
|
||||
private const val NOTIFICATION_ID = 7293450
|
||||
const val NOTIFICATION_ID = 7293450
|
||||
private const val ACTION_CANCEL = App.PACKAGE_NAME + ".local.feed.service.FeedLoadService.CANCEL"
|
||||
|
||||
/**
|
||||
@ -73,27 +56,13 @@ class FeedLoadService : Service() {
|
||||
*/
|
||||
private const val NOTIFICATION_SAMPLING_PERIOD = 1500
|
||||
|
||||
/**
|
||||
* How many extractions will be running in parallel.
|
||||
*/
|
||||
private const val PARALLEL_EXTRACTIONS = 6
|
||||
|
||||
/**
|
||||
* Number of items to buffer to mass-insert in the database.
|
||||
*/
|
||||
private const val BUFFER_COUNT_BEFORE_INSERT = 20
|
||||
|
||||
const val EXTRA_GROUP_ID: String = "FeedLoadService.EXTRA_GROUP_ID"
|
||||
}
|
||||
|
||||
private var loadingSubscription: Subscription? = null
|
||||
private lateinit var subscriptionManager: SubscriptionManager
|
||||
private var loadingDisposable: Disposable? = null
|
||||
private var notificationDisposable: Disposable? = null
|
||||
|
||||
private lateinit var feedDatabaseManager: FeedDatabaseManager
|
||||
private lateinit var feedResultsHolder: ResultsHolder
|
||||
|
||||
private var disposables = CompositeDisposable()
|
||||
private var notificationUpdater = PublishProcessor.create<String>()
|
||||
private lateinit var feedLoadManager: FeedLoadManager
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
// Lifecycle
|
||||
@ -101,8 +70,7 @@ class FeedLoadService : Service() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
subscriptionManager = SubscriptionManager(this)
|
||||
feedDatabaseManager = FeedDatabaseManager(this)
|
||||
feedLoadManager = FeedLoadManager(this)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@ -114,40 +82,45 @@ class FeedLoadService : Service() {
|
||||
)
|
||||
}
|
||||
|
||||
if (intent == null || loadingSubscription != null) {
|
||||
if (intent == null || loadingDisposable != null) {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
setupNotification()
|
||||
setupBroadcastReceiver()
|
||||
val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
|
||||
val groupId = intent.getLongExtra(EXTRA_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID)
|
||||
val useFeedExtractor = defaultSharedPreferences
|
||||
.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false)
|
||||
|
||||
val thresholdOutdatedSecondsString = defaultSharedPreferences
|
||||
.getString(getString(R.string.feed_update_threshold_key), getString(R.string.feed_update_threshold_default_value))
|
||||
val thresholdOutdatedSeconds = thresholdOutdatedSecondsString!!.toInt()
|
||||
|
||||
startLoading(groupId, useFeedExtractor, thresholdOutdatedSeconds)
|
||||
|
||||
loadingDisposable = feedLoadManager.startLoading(groupId)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnSubscribe {
|
||||
startForeground(NOTIFICATION_ID, notificationBuilder.build())
|
||||
}
|
||||
.subscribe { _, error ->
|
||||
// There seems to be a bug in the kotlin plugin as it tells you when
|
||||
// building that this can't be null:
|
||||
// "Condition 'error != null' is always 'true'"
|
||||
// However it can indeed be null
|
||||
// The suppression may be removed in further versions
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
if (error != null) {
|
||||
Log.e(TAG, "Error while storing result", error)
|
||||
handleError(error)
|
||||
return@subscribe
|
||||
}
|
||||
stopService()
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun disposeAll() {
|
||||
unregisterReceiver(broadcastReceiver)
|
||||
|
||||
loadingSubscription?.cancel()
|
||||
loadingSubscription = null
|
||||
|
||||
disposables.dispose()
|
||||
loadingDisposable?.dispose()
|
||||
notificationDisposable?.dispose()
|
||||
}
|
||||
|
||||
private fun stopService() {
|
||||
disposeAll()
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
@ -171,182 +144,6 @@ class FeedLoadService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLoading(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, useFeedExtractor: Boolean, thresholdOutdatedSeconds: Int) {
|
||||
feedResultsHolder = ResultsHolder()
|
||||
|
||||
val outdatedThreshold = OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong())
|
||||
|
||||
val subscriptions = when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
|
||||
else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
|
||||
}
|
||||
|
||||
subscriptions
|
||||
.take(1)
|
||||
.doOnNext {
|
||||
currentProgress.set(0)
|
||||
maxProgress.set(it.size)
|
||||
}
|
||||
.filter { it.isNotEmpty() }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext {
|
||||
startForeground(NOTIFICATION_ID, notificationBuilder.build())
|
||||
updateNotificationProgress(null)
|
||||
broadcastProgress()
|
||||
}
|
||||
.observeOn(Schedulers.io())
|
||||
.flatMap { Flowable.fromIterable(it) }
|
||||
.takeWhile { !cancelSignal.get() }
|
||||
.parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2)
|
||||
.runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
|
||||
.filter { !cancelSignal.get() }
|
||||
.map { subscriptionEntity ->
|
||||
var error: Throwable? = null
|
||||
try {
|
||||
val listInfo = if (useFeedExtractor) {
|
||||
ExtractorHelper
|
||||
.getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url)
|
||||
.onErrorReturn {
|
||||
error = it // store error, otherwise wrapped into RuntimeException
|
||||
throw it
|
||||
}
|
||||
.blockingGet()
|
||||
} else {
|
||||
ExtractorHelper
|
||||
.getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true)
|
||||
.onErrorReturn {
|
||||
error = it // store error, otherwise wrapped into RuntimeException
|
||||
throw it
|
||||
}
|
||||
.blockingGet()
|
||||
} as ListInfo<StreamInfoItem>
|
||||
|
||||
return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo))
|
||||
} catch (e: Throwable) {
|
||||
if (error == null) {
|
||||
// do this to prevent blockingGet() from wrapping into RuntimeException
|
||||
error = e
|
||||
}
|
||||
|
||||
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
|
||||
val wrapper = RequestException(subscriptionEntity.uid, request, error!!)
|
||||
return@map Notification.createOnError<Pair<Long, ListInfo<StreamInfoItem>>>(wrapper)
|
||||
}
|
||||
}
|
||||
.sequential()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnNext(notificationsConsumer)
|
||||
.observeOn(Schedulers.io())
|
||||
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
||||
.doOnNext(databaseConsumer)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(resultSubscriber)
|
||||
}
|
||||
|
||||
private fun broadcastProgress() {
|
||||
postEvent(ProgressEvent(currentProgress.get(), maxProgress.get()))
|
||||
}
|
||||
|
||||
private val resultSubscriber
|
||||
get() = object : Subscriber<List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>> {
|
||||
|
||||
override fun onSubscribe(s: Subscription) {
|
||||
loadingSubscription = s
|
||||
s.request(java.lang.Long.MAX_VALUE)
|
||||
}
|
||||
|
||||
override fun onNext(notification: List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>) {
|
||||
if (DEBUG) Log.v(TAG, "onNext() → $notification")
|
||||
}
|
||||
|
||||
override fun onError(error: Throwable) {
|
||||
handleError(error)
|
||||
}
|
||||
|
||||
override fun onComplete() {
|
||||
if (maxProgress.get() == 0) {
|
||||
postEvent(FeedEventManager.Event.IdleEvent)
|
||||
stopService()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
currentProgress.set(-1)
|
||||
maxProgress.set(-1)
|
||||
|
||||
notificationUpdater.onNext(getString(R.string.feed_processing_message))
|
||||
postEvent(ProgressEvent(R.string.feed_processing_message))
|
||||
|
||||
disposables.add(
|
||||
Single
|
||||
.fromCallable {
|
||||
feedResultsHolder.ready()
|
||||
|
||||
postEvent(ProgressEvent(R.string.feed_processing_message))
|
||||
feedDatabaseManager.removeOrphansOrOlderStreams()
|
||||
|
||||
postEvent(SuccessResultEvent(feedResultsHolder.itemsErrors))
|
||||
true
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { _, throwable ->
|
||||
// There seems to be a bug in the kotlin plugin as it tells you when
|
||||
// building that this can't be null:
|
||||
// "Condition 'throwable != null' is always 'true'"
|
||||
// However it can indeed be null
|
||||
// The suppression may be removed in further versions
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
if (throwable != null) {
|
||||
Log.e(TAG, "Error while storing result", throwable)
|
||||
handleError(throwable)
|
||||
return@subscribe
|
||||
}
|
||||
stopService()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val databaseConsumer: Consumer<List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>>
|
||||
get() = Consumer {
|
||||
feedDatabaseManager.database().runInTransaction {
|
||||
for (notification in it) {
|
||||
|
||||
if (notification.isOnNext) {
|
||||
val subscriptionId = notification.value!!.first
|
||||
val info = notification.value!!.second
|
||||
|
||||
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
|
||||
subscriptionManager.updateFromInfo(subscriptionId, info)
|
||||
|
||||
if (info.errors.isNotEmpty()) {
|
||||
feedResultsHolder.addErrors(RequestException.wrapList(subscriptionId, info))
|
||||
feedDatabaseManager.markAsOutdated(subscriptionId)
|
||||
}
|
||||
} else if (notification.isOnError) {
|
||||
val error = notification.error!!
|
||||
feedResultsHolder.addError(error)
|
||||
|
||||
if (error is RequestException) {
|
||||
feedDatabaseManager.markAsOutdated(error.subscriptionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val notificationsConsumer: Consumer<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>
|
||||
get() = Consumer { onItemCompleted(it.value?.second?.name) }
|
||||
|
||||
private fun onItemCompleted(updateDescription: String?) {
|
||||
currentProgress.incrementAndGet()
|
||||
notificationUpdater.onNext(updateDescription ?: "")
|
||||
|
||||
broadcastProgress()
|
||||
}
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
// Notification
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
@ -354,13 +151,12 @@ class FeedLoadService : Service() {
|
||||
private lateinit var notificationManager: NotificationManagerCompat
|
||||
private lateinit var notificationBuilder: NotificationCompat.Builder
|
||||
|
||||
private var currentProgress = AtomicInteger(-1)
|
||||
private var maxProgress = AtomicInteger(-1)
|
||||
|
||||
private fun createNotification(): NotificationCompat.Builder {
|
||||
val cancelActionIntent = PendingIntent.getBroadcast(
|
||||
this,
|
||||
NOTIFICATION_ID, Intent(ACTION_CANCEL), 0
|
||||
NOTIFICATION_ID,
|
||||
Intent(ACTION_CANCEL),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
|
||||
@ -376,33 +172,36 @@ class FeedLoadService : Service() {
|
||||
notificationManager = NotificationManagerCompat.from(this)
|
||||
notificationBuilder = createNotification()
|
||||
|
||||
val throttleAfterFirstEmission = Function { flow: Flowable<String> ->
|
||||
val throttleAfterFirstEmission = Function { flow: Flowable<FeedLoadState> ->
|
||||
flow.take(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS))
|
||||
}
|
||||
|
||||
disposables.add(
|
||||
notificationUpdater
|
||||
.publish(throttleAfterFirstEmission)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(this::updateNotificationProgress)
|
||||
)
|
||||
notificationDisposable = feedLoadManager.notification
|
||||
.publish(throttleAfterFirstEmission)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnTerminate { notificationManager.cancel(NOTIFICATION_ID) }
|
||||
.subscribe(this::updateNotificationProgress)
|
||||
}
|
||||
|
||||
private fun updateNotificationProgress(updateDescription: String?) {
|
||||
notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1)
|
||||
private fun updateNotificationProgress(state: FeedLoadState) {
|
||||
notificationBuilder.setProgress(state.maxProgress, state.currentProgress, state.maxProgress == -1)
|
||||
|
||||
if (maxProgress.get() == -1) {
|
||||
if (state.maxProgress == -1) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null)
|
||||
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
|
||||
notificationBuilder.setContentText(updateDescription)
|
||||
if (state.updateDescription.isNotEmpty()) notificationBuilder.setContentText(state.updateDescription)
|
||||
notificationBuilder.setContentText(state.updateDescription)
|
||||
} else {
|
||||
val progressText = this.currentProgress.toString() + "/" + maxProgress
|
||||
val progressText = state.currentProgress.toString() + "/" + state.maxProgress
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText("$updateDescription ($progressText)")
|
||||
if (state.updateDescription.isNotEmpty()) {
|
||||
notificationBuilder.setContentText("${state.updateDescription} ($progressText)")
|
||||
}
|
||||
} else {
|
||||
notificationBuilder.setContentInfo(progressText)
|
||||
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
|
||||
if (state.updateDescription.isNotEmpty()) {
|
||||
notificationBuilder.setContentText(state.updateDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -414,13 +213,12 @@ class FeedLoadService : Service() {
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private lateinit var broadcastReceiver: BroadcastReceiver
|
||||
private val cancelSignal = AtomicBoolean()
|
||||
|
||||
private fun setupBroadcastReceiver() {
|
||||
broadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == ACTION_CANCEL) {
|
||||
cancelSignal.set(true)
|
||||
feedLoadManager.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -435,29 +233,4 @@ class FeedLoadService : Service() {
|
||||
postEvent(ErrorResultEvent(error))
|
||||
stopService()
|
||||
}
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
// Results Holder
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
|
||||
class ResultsHolder {
|
||||
/**
|
||||
* List of errors that may have happen during loading.
|
||||
*/
|
||||
internal lateinit var itemsErrors: List<Throwable>
|
||||
|
||||
private val itemsErrorsHolder: MutableList<Throwable> = ArrayList()
|
||||
|
||||
fun addError(error: Throwable) {
|
||||
itemsErrorsHolder.add(error)
|
||||
}
|
||||
|
||||
fun addErrors(errors: List<Throwable>) {
|
||||
itemsErrorsHolder.addAll(errors)
|
||||
}
|
||||
|
||||
fun ready() {
|
||||
itemsErrors = itemsErrorsHolder.toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,7 @@
|
||||
package org.schabi.newpipe.local.feed.service
|
||||
|
||||
data class FeedLoadState(
|
||||
val updateDescription: String,
|
||||
val maxProgress: Int,
|
||||
val currentProgress: Int,
|
||||
)
|
@ -0,0 +1,19 @@
|
||||
package org.schabi.newpipe.local.feed.service
|
||||
|
||||
class FeedResultsHolder {
|
||||
/**
|
||||
* List of errors that may have happen during loading.
|
||||
*/
|
||||
val itemsErrors: List<Throwable>
|
||||
get() = itemsErrorsHolder
|
||||
|
||||
private val itemsErrorsHolder: MutableList<Throwable> = ArrayList()
|
||||
|
||||
fun addError(error: Throwable) {
|
||||
itemsErrorsHolder.add(error)
|
||||
}
|
||||
|
||||
fun addErrors(errors: List<Throwable>) {
|
||||
itemsErrorsHolder.addAll(errors)
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
package org.schabi.newpipe.local.feed.service
|
||||
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import org.schabi.newpipe.extractor.ListInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
|
||||
data class FeedUpdateInfo(
|
||||
val uid: Long,
|
||||
@NotificationMode
|
||||
val notificationMode: Int,
|
||||
val name: String,
|
||||
val avatarUrl: String,
|
||||
val listInfo: ListInfo<StreamInfoItem>,
|
||||
) {
|
||||
constructor(
|
||||
subscription: SubscriptionEntity,
|
||||
listInfo: ListInfo<StreamInfoItem>,
|
||||
) : this(
|
||||
uid = subscription.uid,
|
||||
notificationMode = subscription.notificationMode,
|
||||
name = subscription.name,
|
||||
avatarUrl = subscription.avatarUrl,
|
||||
listInfo = listInfo,
|
||||
)
|
||||
|
||||
/**
|
||||
* Integer id, can be used as notification id, etc.
|
||||
*/
|
||||
val pseudoId: Int
|
||||
get() = listInfo.url.hashCode()
|
||||
|
||||
lateinit var newStreams: List<StreamInfoItem>
|
||||
}
|
@ -84,7 +84,7 @@ public abstract class HistoryEntryAdapter<E, VH extends RecyclerView.ViewHolder>
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewRecycled(final VH holder) {
|
||||
public void onViewRecycled(@NonNull final VH holder) {
|
||||
super.onViewRecycled(holder);
|
||||
holder.itemView.setOnClickListener(null);
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
package org.schabi.newpipe.local.history;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.os.Parcelable;
|
||||
@ -29,20 +28,16 @@ import org.schabi.newpipe.databinding.StatisticPlaylistControlBinding;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.info_list.InfoItemDialog;
|
||||
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
|
||||
import org.schabi.newpipe.local.BaseLocalListFragment;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
||||
import org.schabi.newpipe.settings.HistorySettingsFragment;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.OnClickGesture;
|
||||
import org.schabi.newpipe.util.StreamDialogEntry;
|
||||
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
@ -154,7 +149,7 @@ public class StatisticsPlaylistFragment
|
||||
@Override
|
||||
public void held(final LocalItem selectedItem) {
|
||||
if (selectedItem instanceof StreamStatisticsEntry) {
|
||||
showStreamDialog((StreamStatisticsEntry) selectedItem);
|
||||
showInfoItemDialog((StreamStatisticsEntry) selectedItem);
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -328,66 +323,30 @@ public class StatisticsPlaylistFragment
|
||||
return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0));
|
||||
}
|
||||
|
||||
private void showStreamDialog(final StreamStatisticsEntry item) {
|
||||
private void showInfoItemDialog(final StreamStatisticsEntry item) {
|
||||
final Context context = getContext();
|
||||
final Activity activity = getActivity();
|
||||
if (context == null || context.getResources() == null || activity == null) {
|
||||
return;
|
||||
}
|
||||
final StreamInfoItem infoItem = item.toStreamInfoItem();
|
||||
|
||||
final ArrayList<StreamDialogEntry> entries = new ArrayList<>();
|
||||
try {
|
||||
final InfoItemDialog.Builder dialogBuilder =
|
||||
new InfoItemDialog.Builder(getActivity(), context, this, infoItem);
|
||||
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
||||
entries.add(StreamDialogEntry.enqueue);
|
||||
|
||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
||||
entries.add(StreamDialogEntry.enqueue_next);
|
||||
}
|
||||
// set entries in the middle; the others are added automatically
|
||||
dialogBuilder
|
||||
.addEntry(StreamDialogDefaultEntry.DELETE)
|
||||
.setAction(
|
||||
StreamDialogDefaultEntry.DELETE,
|
||||
(f, i) -> deleteEntry(
|
||||
Math.max(itemListAdapter.getItemsList().indexOf(item), 0)))
|
||||
.setAction(
|
||||
StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND,
|
||||
(f, i) -> NavigationHelper.playOnBackgroundPlayer(
|
||||
context, getPlayQueueStartingAt(item), true))
|
||||
.create()
|
||||
.show();
|
||||
} catch (final IllegalArgumentException e) {
|
||||
InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem);
|
||||
}
|
||||
|
||||
if (infoItem.getStreamType() == StreamType.AUDIO_STREAM) {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.delete,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
} else {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.start_here_on_popup,
|
||||
StreamDialogEntry.delete,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
}
|
||||
entries.add(StreamDialogEntry.open_in_browser);
|
||||
if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
|
||||
entries.add(StreamDialogEntry.play_with_kodi);
|
||||
}
|
||||
|
||||
// show "mark as watched" only when watch history is enabled
|
||||
if (StreamDialogEntry.shouldAddMarkAsWatched(
|
||||
item.getStreamEntity().getStreamType(),
|
||||
context
|
||||
)) {
|
||||
entries.add(
|
||||
StreamDialogEntry.mark_as_watched
|
||||
);
|
||||
}
|
||||
entries.add(StreamDialogEntry.show_channel_details);
|
||||
|
||||
StreamDialogEntry.setEnabledEntries(entries);
|
||||
|
||||
StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItemDuplicate) ->
|
||||
NavigationHelper
|
||||
.playOnBackgroundPlayer(context, getPlayQueueStartingAt(item), true));
|
||||
StreamDialogEntry.delete.setCustomAction((fragment, infoItemDuplicate) ->
|
||||
deleteEntry(Math.max(itemListAdapter.getItemsList().indexOf(item), 0)));
|
||||
|
||||
new InfoItemDialog(activity, infoItem, StreamDialogEntry.getCommands(context),
|
||||
(dialog, which) -> StreamDialogEntry.clickOn(which, this, infoItem)).show();
|
||||
}
|
||||
|
||||
private void deleteEntry(final int index) {
|
||||
|
@ -1,6 +1,8 @@
|
||||
package org.schabi.newpipe.local.playlist;
|
||||
|
||||
import android.app.Activity;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Bundle;
|
||||
@ -38,22 +40,18 @@ import org.schabi.newpipe.databinding.PlaylistControlBinding;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.info_list.InfoItemDialog;
|
||||
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
|
||||
import org.schabi.newpipe.local.BaseLocalListFragment;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.player.MainPlayer.PlayerType;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.OnClickGesture;
|
||||
import org.schabi.newpipe.util.StreamDialogEntry;
|
||||
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
@ -68,9 +66,6 @@ import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject;
|
||||
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
|
||||
|
||||
public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistStreamEntry>, Void> {
|
||||
// Save the list 10 seconds after the last change occurred
|
||||
private static final long SAVE_DEBOUNCE_MILLIS = 10000;
|
||||
@ -182,7 +177,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||
@Override
|
||||
public void held(final LocalItem selectedItem) {
|
||||
if (selectedItem instanceof PlaylistStreamEntry) {
|
||||
showStreamItemDialog((PlaylistStreamEntry) selectedItem);
|
||||
showInfoItemDialog((PlaylistStreamEntry) selectedItem);
|
||||
}
|
||||
}
|
||||
|
||||
@ -355,7 +350,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setMessage(R.string.remove_watched_popup_warning)
|
||||
.setTitle(R.string.remove_watched_popup_title)
|
||||
.setPositiveButton(R.string.yes,
|
||||
.setPositiveButton(R.string.ok,
|
||||
(DialogInterface d, int id) -> removeWatchedStreams(false))
|
||||
.setNeutralButton(
|
||||
R.string.remove_watched_popup_yes_and_partially_watched_videos,
|
||||
@ -743,70 +738,39 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||
return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0));
|
||||
}
|
||||
|
||||
protected void showStreamItemDialog(final PlaylistStreamEntry item) {
|
||||
final Context context = getContext();
|
||||
final Activity activity = getActivity();
|
||||
if (context == null || context.getResources() == null || activity == null) {
|
||||
return;
|
||||
}
|
||||
protected void showInfoItemDialog(final PlaylistStreamEntry item) {
|
||||
final StreamInfoItem infoItem = item.toStreamInfoItem();
|
||||
|
||||
final ArrayList<StreamDialogEntry> entries = new ArrayList<>();
|
||||
try {
|
||||
final Context context = getContext();
|
||||
final InfoItemDialog.Builder dialogBuilder =
|
||||
new InfoItemDialog.Builder(getActivity(), context, this, infoItem);
|
||||
|
||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
||||
entries.add(StreamDialogEntry.enqueue);
|
||||
|
||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
||||
entries.add(StreamDialogEntry.enqueue_next);
|
||||
}
|
||||
}
|
||||
if (infoItem.getStreamType() == StreamType.AUDIO_STREAM) {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.set_as_playlist_thumbnail,
|
||||
StreamDialogEntry.delete,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
} else {
|
||||
entries.addAll(Arrays.asList(
|
||||
StreamDialogEntry.start_here_on_background,
|
||||
StreamDialogEntry.start_here_on_popup,
|
||||
StreamDialogEntry.set_as_playlist_thumbnail,
|
||||
StreamDialogEntry.delete,
|
||||
StreamDialogEntry.append_playlist,
|
||||
StreamDialogEntry.share
|
||||
));
|
||||
}
|
||||
entries.add(StreamDialogEntry.open_in_browser);
|
||||
if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) {
|
||||
entries.add(StreamDialogEntry.play_with_kodi);
|
||||
}
|
||||
|
||||
// show "mark as watched" only when watch history is enabled
|
||||
if (StreamDialogEntry.shouldAddMarkAsWatched(
|
||||
item.getStreamEntity().getStreamType(),
|
||||
context
|
||||
)) {
|
||||
entries.add(
|
||||
StreamDialogEntry.mark_as_watched
|
||||
// add entries in the middle
|
||||
dialogBuilder.addAllEntries(
|
||||
StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL,
|
||||
StreamDialogDefaultEntry.DELETE
|
||||
);
|
||||
|
||||
// set custom actions
|
||||
// all entries modified below have already been added within the builder
|
||||
dialogBuilder
|
||||
.setAction(
|
||||
StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND,
|
||||
(f, i) -> NavigationHelper.playOnBackgroundPlayer(
|
||||
context, getPlayQueueStartingAt(item), true))
|
||||
.setAction(
|
||||
StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL,
|
||||
(f, i) ->
|
||||
changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl()))
|
||||
.setAction(
|
||||
StreamDialogDefaultEntry.DELETE,
|
||||
(f, i) -> deleteItem(item))
|
||||
.create()
|
||||
.show();
|
||||
} catch (final IllegalArgumentException e) {
|
||||
InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem);
|
||||
}
|
||||
entries.add(StreamDialogEntry.show_channel_details);
|
||||
|
||||
StreamDialogEntry.setEnabledEntries(entries);
|
||||
|
||||
StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItemDuplicate) ->
|
||||
NavigationHelper.playOnBackgroundPlayer(context,
|
||||
getPlayQueueStartingAt(item), true));
|
||||
StreamDialogEntry.set_as_playlist_thumbnail.setCustomAction(
|
||||
(fragment, infoItemDuplicate) ->
|
||||
changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl()));
|
||||
StreamDialogEntry.delete.setCustomAction((fragment, infoItemDuplicate) ->
|
||||
deleteItem(item));
|
||||
|
||||
new InfoItemDialog(activity, infoItem, StreamDialogEntry.getCommands(context),
|
||||
(dialog, which) -> StreamDialogEntry.clickOn(which, this, infoItem)).show();
|
||||
}
|
||||
|
||||
private void setInitialData(final long pid, final String title) {
|
||||
|
@ -61,7 +61,7 @@ public class ImportConfirmationDialog extends DialogFragment {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(final Bundle outState) {
|
||||
public void onSaveInstanceState(@NonNull final Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
Icepick.saveInstanceState(this, outState);
|
||||
}
|
||||
|
@ -7,6 +7,8 @@ import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionDAO
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import org.schabi.newpipe.extractor.ListInfo
|
||||
@ -14,6 +16,7 @@ import org.schabi.newpipe.extractor.channel.ChannelInfo
|
||||
import org.schabi.newpipe.extractor.feed.FeedInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
|
||||
class SubscriptionManager(context: Context) {
|
||||
private val database = NewPipeDatabase.getInstance(context)
|
||||
@ -66,13 +69,33 @@ class SubscriptionManager(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable {
|
||||
return subscriptionTable().getSubscription(serviceId, url)
|
||||
.flatMapCompletable { entity: SubscriptionEntity ->
|
||||
Completable.fromAction {
|
||||
entity.notificationMode = mode
|
||||
subscriptionTable().update(entity)
|
||||
}.apply {
|
||||
if (mode != NotificationMode.DISABLED) {
|
||||
// notifications have just been enabled, mark all streams as "old"
|
||||
andThen(rememberAllStreams(entity))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFromInfo(subscriptionId: Long, info: ListInfo<StreamInfoItem>) {
|
||||
val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId)
|
||||
|
||||
if (info is FeedInfo) {
|
||||
subscriptionEntity.name = info.name
|
||||
} else if (info is ChannelInfo) {
|
||||
subscriptionEntity.setData(info.name, info.avatarUrl, info.description, info.subscriberCount)
|
||||
subscriptionEntity.setData(
|
||||
info.name,
|
||||
info.avatarUrl,
|
||||
info.description,
|
||||
info.subscriberCount
|
||||
)
|
||||
}
|
||||
|
||||
subscriptionTable.update(subscriptionEntity)
|
||||
@ -94,4 +117,19 @@ class SubscriptionManager(context: Context) {
|
||||
fun deleteSubscription(subscriptionEntity: SubscriptionEntity) {
|
||||
subscriptionTable.delete(subscriptionEntity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the list of videos for the provided channel and saves them in the database, so that
|
||||
* they will be considered as "old"/"already seen" streams and the user will never be notified
|
||||
* about any one of them.
|
||||
*/
|
||||
private fun rememberAllStreams(subscription: SubscriptionEntity): Completable {
|
||||
return ExtractorHelper.getChannelInfo(subscription.serviceId, subscription.url, false)
|
||||
.map { channel -> channel.relatedItems.map { stream -> StreamEntity(stream) } }
|
||||
.flatMapCompletable { entities ->
|
||||
Completable.fromAction {
|
||||
database.streamDAO().upsertAll(entities)
|
||||
}
|
||||
}.onErrorComplete()
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,6 @@ import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.functions.BiFunction
|
||||
import io.reactivex.rxjava3.processors.BehaviorProcessor
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
@ -33,9 +32,8 @@ class FeedGroupDialogViewModel(
|
||||
private var subscriptionsFlowable = Flowable
|
||||
.combineLatest(
|
||||
filterSubscriptions.startWithItem(initialQuery),
|
||||
toggleShowOnlyUngrouped.startWithItem(initialShowOnlyUngrouped),
|
||||
BiFunction { t1: String, t2: Boolean -> Filter(t1, t2) }
|
||||
)
|
||||
toggleShowOnlyUngrouped.startWithItem(initialShowOnlyUngrouped)
|
||||
) { t1: String, t2: Boolean -> Filter(t1, t2) }
|
||||
.distinctUntilChanged()
|
||||
.switchMap { (query, showOnlyUngrouped) ->
|
||||
subscriptionManager.getSubscriptions(groupId, query, showOnlyUngrouped)
|
||||
@ -56,9 +54,8 @@ class FeedGroupDialogViewModel(
|
||||
|
||||
private var subscriptionsDisposable = Flowable
|
||||
.combineLatest(
|
||||
subscriptionsFlowable, feedDatabaseManager.subscriptionIdsForGroup(groupId),
|
||||
BiFunction { t1: List<PickerSubscriptionItem>, t2: List<Long> -> t1 to t2.toSet() }
|
||||
)
|
||||
subscriptionsFlowable, feedDatabaseManager.subscriptionIdsForGroup(groupId)
|
||||
) { t1: List<PickerSubscriptionItem>, t2: List<Long> -> t1 to t2.toSet() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe(mutableSubscriptionsLiveData::postValue)
|
||||
|
||||
|
@ -57,15 +57,12 @@ class FeedGroupReorderDialog : DialogFragment() {
|
||||
|
||||
viewModel = ViewModelProvider(this).get(FeedGroupReorderDialogViewModel::class.java)
|
||||
viewModel.groupsLiveData.observe(viewLifecycleOwner, Observer(::handleGroups))
|
||||
viewModel.dialogEventLiveData.observe(
|
||||
viewLifecycleOwner,
|
||||
Observer {
|
||||
when (it) {
|
||||
ProcessingEvent -> disableInput()
|
||||
SuccessEvent -> dismiss()
|
||||
}
|
||||
viewModel.dialogEventLiveData.observe(viewLifecycleOwner) {
|
||||
when (it) {
|
||||
ProcessingEvent -> disableInput()
|
||||
SuccessEvent -> dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
binding.feedGroupsList.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.feedGroupsList.adapter = groupAdapter
|
||||
|
@ -25,7 +25,6 @@ import com.grack.nanojson.JsonAppendableWriter;
|
||||
import com.grack.nanojson.JsonArray;
|
||||
import com.grack.nanojson.JsonObject;
|
||||
import com.grack.nanojson.JsonParser;
|
||||
import com.grack.nanojson.JsonSink;
|
||||
import com.grack.nanojson.JsonWriter;
|
||||
|
||||
import org.schabi.newpipe.BuildConfig;
|
||||
@ -125,10 +124,11 @@ public final class ImportExportJsonHelper {
|
||||
/**
|
||||
* @see #writeTo(List, OutputStream, ImportExportEventListener)
|
||||
* @param items the list of subscriptions items
|
||||
* @param writer the output {@link JsonSink}
|
||||
* @param writer the output {@link JsonAppendableWriter}
|
||||
* @param eventListener listener for the events generated
|
||||
*/
|
||||
public static void writeTo(final List<SubscriptionItem> items, final JsonSink writer,
|
||||
public static void writeTo(final List<SubscriptionItem> items,
|
||||
final JsonAppendableWriter writer,
|
||||
@Nullable final ImportExportEventListener eventListener) {
|
||||
if (eventListener != null) {
|
||||
eventListener.onSizeReceived(items.size());
|
||||
|
@ -1,7 +1,10 @@
|
||||
package org.schabi.newpipe.player;
|
||||
|
||||
import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.ServiceConnection;
|
||||
import android.os.Bundle;
|
||||
@ -23,11 +26,9 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.databinding.ActivityPlayerQueueControlBinding;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
|
||||
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||
import org.schabi.newpipe.player.event.PlayerEventListener;
|
||||
import org.schabi.newpipe.player.helper.PlaybackParameterDialog;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
@ -42,13 +43,6 @@ import org.schabi.newpipe.util.PermissionHelper;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
public final class PlayQueueActivity extends AppCompatActivity
|
||||
implements PlayerEventListener, SeekBar.OnSeekBarChangeListener,
|
||||
View.OnClickListener, PlaybackParameterDialog.Callback {
|
||||
@ -129,7 +123,7 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||
NavigationHelper.openSettings(this);
|
||||
return true;
|
||||
case R.id.action_append_playlist:
|
||||
appendAllToPlaylist();
|
||||
player.onAddToPlaylistClicked(getSupportFragmentManager());
|
||||
return true;
|
||||
case R.id.action_playback_speed:
|
||||
openPlaybackParameterDialog();
|
||||
@ -443,24 +437,6 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||
seeking = false;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Playlist append
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private void appendAllToPlaylist() {
|
||||
if (player != null && player.getPlayQueue() != null) {
|
||||
openPlaylistAppendDialog(player.getPlayQueue().getStreams());
|
||||
}
|
||||
}
|
||||
|
||||
private void openPlaylistAppendDialog(final List<PlayQueueItem> playQueueItems) {
|
||||
PlaylistDialog.createCorrespondingDialog(
|
||||
getApplicationContext(),
|
||||
playQueueItems.stream().map(StreamEntity::new).collect(Collectors.toList()),
|
||||
dialog -> dialog.show(getSupportFragmentManager(), TAG)
|
||||
);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Binding Service Listener
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
@ -624,7 +600,6 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||
|
||||
//2) Icon change accordingly to current App Theme
|
||||
// using rootView.getContext() because getApplicationContext() didn't work
|
||||
final Context context = queueControlBinding.getRoot().getContext();
|
||||
item.setIcon(player.isMuted() ? R.drawable.ic_volume_off : R.drawable.ic_volume_up);
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
package org.schabi.newpipe.player.event;
|
||||
|
||||
import com.google.android.exoplayer2.ExoPlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
|
||||
public interface PlayerServiceEventListener extends PlayerEventListener {
|
||||
void onFullscreenStateChanged(boolean fullscreen);
|
||||
@ -9,7 +9,7 @@ public interface PlayerServiceEventListener extends PlayerEventListener {
|
||||
|
||||
void onMoreOptionsLongClicked();
|
||||
|
||||
void onPlayerError(ExoPlaybackException error);
|
||||
void onPlayerError(PlaybackException error, boolean isCatchableException);
|
||||
|
||||
void hideSystemUiIfNeeded();
|
||||
}
|
||||
|
@ -14,7 +14,7 @@ import androidx.core.content.ContextCompat;
|
||||
import androidx.media.AudioFocusRequestCompat;
|
||||
import androidx.media.AudioManagerCompat;
|
||||
|
||||
import com.google.android.exoplayer2.SimpleExoPlayer;
|
||||
import com.google.android.exoplayer2.ExoPlayer;
|
||||
import com.google.android.exoplayer2.analytics.AnalyticsListener;
|
||||
|
||||
public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, AnalyticsListener {
|
||||
@ -27,14 +27,14 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, An
|
||||
private static final int FOCUS_GAIN_TYPE = AudioManagerCompat.AUDIOFOCUS_GAIN;
|
||||
private static final int STREAM_TYPE = AudioManager.STREAM_MUSIC;
|
||||
|
||||
private final SimpleExoPlayer player;
|
||||
private final ExoPlayer player;
|
||||
private final Context context;
|
||||
private final AudioManager audioManager;
|
||||
|
||||
private final AudioFocusRequestCompat request;
|
||||
|
||||
public AudioReactor(@NonNull final Context context,
|
||||
@NonNull final SimpleExoPlayer player) {
|
||||
@NonNull final ExoPlayer player) {
|
||||
this.player = player;
|
||||
this.context = context;
|
||||
this.audioManager = ContextCompat.getSystemService(context, AudioManager.class);
|
||||
@ -149,7 +149,8 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, An
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onAudioSessionIdChanged(final EventTime eventTime, final int audioSessionId) {
|
||||
public void onAudioSessionIdChanged(@NonNull final EventTime eventTime,
|
||||
final int audioSessionId) {
|
||||
notifyAudioSessionUpdate(true, audioSessionId);
|
||||
}
|
||||
private void notifyAudioSessionUpdate(final boolean active, final int audioSessionId) {
|
||||
|
@ -3,12 +3,10 @@ package org.schabi.newpipe.player.helper;
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.android.exoplayer2.database.ExoDatabaseProvider;
|
||||
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider;
|
||||
import com.google.android.exoplayer2.upstream.DataSource;
|
||||
import com.google.android.exoplayer2.upstream.DefaultDataSource;
|
||||
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource;
|
||||
import com.google.android.exoplayer2.upstream.FileDataSource;
|
||||
import com.google.android.exoplayer2.upstream.TransferListener;
|
||||
import com.google.android.exoplayer2.upstream.cache.CacheDataSink;
|
||||
@ -18,6 +16,8 @@ import com.google.android.exoplayer2.upstream.cache.SimpleCache;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
/* package-private */ class CacheFactory implements DataSource.Factory {
|
||||
private static final String TAG = "CacheFactory";
|
||||
|
||||
@ -25,7 +25,7 @@ import java.io.File;
|
||||
private static final int CACHE_FLAGS = CacheDataSource.FLAG_BLOCK_ON_CACHE
|
||||
| CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR;
|
||||
|
||||
private final DefaultDataSourceFactory dataSourceFactory;
|
||||
private final DataSource.Factory dataSourceFactory;
|
||||
private final File cacheDir;
|
||||
private final long maxFileSize;
|
||||
|
||||
@ -49,7 +49,9 @@ import java.io.File;
|
||||
final long maxFileSize) {
|
||||
this.maxFileSize = maxFileSize;
|
||||
|
||||
dataSourceFactory = new DefaultDataSourceFactory(context, userAgent, transferListener);
|
||||
dataSourceFactory = new DefaultDataSource
|
||||
.Factory(context, new DefaultHttpDataSource.Factory().setUserAgent(userAgent))
|
||||
.setTransferListener(transferListener);
|
||||
cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME);
|
||||
if (!cacheDir.exists()) {
|
||||
//noinspection ResultOfMethodCallIgnored
|
||||
@ -59,15 +61,16 @@ import java.io.File;
|
||||
if (cache == null) {
|
||||
final LeastRecentlyUsedCacheEvictor evictor
|
||||
= new LeastRecentlyUsedCacheEvictor(maxCacheSize);
|
||||
cache = new SimpleCache(cacheDir, evictor, new ExoDatabaseProvider(context));
|
||||
cache = new SimpleCache(cacheDir, evictor, new StandaloneDatabaseProvider(context));
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public DataSource createDataSource() {
|
||||
Log.d(TAG, "initExoPlayerCache: cacheDir = " + cacheDir.getAbsolutePath());
|
||||
|
||||
final DefaultDataSource dataSource = dataSourceFactory.createDataSource();
|
||||
final DataSource dataSource = dataSourceFactory.createDataSource();
|
||||
final FileDataSource fileSource = new FileDataSource();
|
||||
final CacheDataSink dataSink = new CacheDataSink(cache, maxFileSize);
|
||||
|
||||
@ -86,8 +89,8 @@ import java.io.File;
|
||||
|
||||
Log.d(TAG, "tryDeleteCacheFiles: " + filePath + " deleted = " + deleteSuccessful);
|
||||
}
|
||||
} catch (final Exception ignored) {
|
||||
Log.e(TAG, "Failed to delete file.", ignored);
|
||||
} catch (final Exception e) {
|
||||
Log.e(TAG, "Failed to delete file.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -19,7 +19,6 @@ import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||
import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.player.mediasession.MediaSessionCallback;
|
||||
import org.schabi.newpipe.player.mediasession.PlayQueueNavigator;
|
||||
import org.schabi.newpipe.player.mediasession.PlayQueuePlaybackController;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@ -55,7 +54,6 @@ public class MediaSessionManager {
|
||||
.build());
|
||||
|
||||
sessionConnector = new MediaSessionConnector(mediaSession);
|
||||
sessionConnector.setControlDispatcher(new PlayQueuePlaybackController(callback));
|
||||
sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, callback));
|
||||
sessionConnector.setPlayer(player);
|
||||
}
|
||||
@ -135,9 +133,7 @@ public class MediaSessionManager {
|
||||
lastTitleHashCode = title.hashCode();
|
||||
lastArtistHashCode = artist.hashCode();
|
||||
lastDuration = duration;
|
||||
if (optAlbumArt.isPresent()) {
|
||||
lastAlbumArtHashCode = optAlbumArt.get().hashCode();
|
||||
}
|
||||
optAlbumArt.ifPresent(bitmap -> lastAlbumArtHashCode = bitmap.hashCode());
|
||||
}
|
||||
|
||||
private boolean checkIfMetadataShouldBeSet(
|
||||
|
@ -9,6 +9,7 @@ import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.widget.CheckBox;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.SeekBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
@ -19,6 +20,7 @@ import androidx.fragment.app.DialogFragment;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
|
||||
import org.schabi.newpipe.util.SliderStrategy;
|
||||
|
||||
public class PlaybackParameterDialog extends DialogFragment {
|
||||
@ -37,6 +39,7 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
|
||||
private static final double DEFAULT_TEMPO = 1.00f;
|
||||
private static final double DEFAULT_PITCH = 1.00f;
|
||||
private static final int DEFAULT_SEMITONES = 0;
|
||||
private static final double DEFAULT_STEP = STEP_TWENTY_FIVE_PERCENT_VALUE;
|
||||
private static final boolean DEFAULT_SKIP_SILENCE = false;
|
||||
|
||||
@ -64,10 +67,11 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
|
||||
private double initialTempo = DEFAULT_TEMPO;
|
||||
private double initialPitch = DEFAULT_PITCH;
|
||||
private int initialSemitones = DEFAULT_SEMITONES;
|
||||
private boolean initialSkipSilence = DEFAULT_SKIP_SILENCE;
|
||||
private double tempo = DEFAULT_TEMPO;
|
||||
private double pitch = DEFAULT_PITCH;
|
||||
private double stepSize = DEFAULT_STEP;
|
||||
private int semitones = DEFAULT_SEMITONES;
|
||||
|
||||
@Nullable
|
||||
private SeekBar tempoSlider;
|
||||
@ -86,9 +90,19 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
@Nullable
|
||||
private TextView pitchStepUpText;
|
||||
@Nullable
|
||||
private SeekBar semitoneSlider;
|
||||
@Nullable
|
||||
private TextView semitoneCurrentText;
|
||||
@Nullable
|
||||
private TextView semitoneStepDownText;
|
||||
@Nullable
|
||||
private TextView semitoneStepUpText;
|
||||
@Nullable
|
||||
private CheckBox unhookingCheckbox;
|
||||
@Nullable
|
||||
private CheckBox skipSilenceCheckbox;
|
||||
@Nullable
|
||||
private CheckBox adjustBySemitonesCheckbox;
|
||||
|
||||
public static PlaybackParameterDialog newInstance(final double playbackTempo,
|
||||
final double playbackPitch,
|
||||
@ -101,6 +115,7 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
|
||||
dialog.tempo = playbackTempo;
|
||||
dialog.pitch = playbackPitch;
|
||||
dialog.semitones = dialog.percentToSemitones(playbackPitch);
|
||||
|
||||
dialog.initialSkipSilence = playbackSkipSilence;
|
||||
return dialog;
|
||||
@ -111,7 +126,7 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onAttach(final Context context) {
|
||||
public void onAttach(@NonNull final Context context) {
|
||||
super.onAttach(context);
|
||||
if (context instanceof Callback) {
|
||||
callback = (Callback) context;
|
||||
@ -127,22 +142,22 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
if (savedInstanceState != null) {
|
||||
initialTempo = savedInstanceState.getDouble(INITIAL_TEMPO_KEY, DEFAULT_TEMPO);
|
||||
initialPitch = savedInstanceState.getDouble(INITIAL_PITCH_KEY, DEFAULT_PITCH);
|
||||
initialSemitones = percentToSemitones(initialPitch);
|
||||
|
||||
tempo = savedInstanceState.getDouble(TEMPO_KEY, DEFAULT_TEMPO);
|
||||
pitch = savedInstanceState.getDouble(PITCH_KEY, DEFAULT_PITCH);
|
||||
stepSize = savedInstanceState.getDouble(STEP_SIZE_KEY, DEFAULT_STEP);
|
||||
semitones = percentToSemitones(pitch);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(final Bundle outState) {
|
||||
public void onSaveInstanceState(@NonNull final Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
outState.putDouble(INITIAL_TEMPO_KEY, initialTempo);
|
||||
outState.putDouble(INITIAL_PITCH_KEY, initialPitch);
|
||||
|
||||
outState.putDouble(TEMPO_KEY, getCurrentTempo());
|
||||
outState.putDouble(PITCH_KEY, getCurrentPitch());
|
||||
outState.putDouble(STEP_SIZE_KEY, getCurrentStepSize());
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
@ -160,9 +175,11 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
.setView(view)
|
||||
.setCancelable(true)
|
||||
.setNegativeButton(R.string.cancel, (dialogInterface, i) ->
|
||||
setPlaybackParameters(initialTempo, initialPitch, initialSkipSilence))
|
||||
setPlaybackParameters(initialTempo, initialPitch,
|
||||
initialSemitones, initialSkipSilence))
|
||||
.setNeutralButton(R.string.playback_reset, (dialogInterface, i) ->
|
||||
setPlaybackParameters(DEFAULT_TEMPO, DEFAULT_PITCH, DEFAULT_SKIP_SILENCE))
|
||||
setPlaybackParameters(DEFAULT_TEMPO, DEFAULT_PITCH,
|
||||
DEFAULT_SEMITONES, DEFAULT_SKIP_SILENCE))
|
||||
.setPositiveButton(R.string.ok, (dialogInterface, i) ->
|
||||
setCurrentPlaybackParameters());
|
||||
|
||||
@ -176,14 +193,49 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
private void setupControlViews(@NonNull final View rootView) {
|
||||
setupHookingControl(rootView);
|
||||
setupSkipSilenceControl(rootView);
|
||||
setupAdjustBySemitonesControl(rootView);
|
||||
|
||||
setupTempoControl(rootView);
|
||||
setupPitchControl(rootView);
|
||||
setupSemitoneControl(rootView);
|
||||
|
||||
togglePitchSliderType(rootView);
|
||||
|
||||
setStepSize(stepSize);
|
||||
setupStepSizeSelector(rootView);
|
||||
}
|
||||
|
||||
private void togglePitchSliderType(@NonNull final View rootView) {
|
||||
final RelativeLayout pitchControl = rootView.findViewById(R.id.pitchControl);
|
||||
final RelativeLayout semitoneControl = rootView.findViewById(R.id.semitoneControl);
|
||||
|
||||
final View separatorStepSizeSelector =
|
||||
rootView.findViewById(R.id.separatorStepSizeSelector);
|
||||
final RelativeLayout.LayoutParams params =
|
||||
(RelativeLayout.LayoutParams) separatorStepSizeSelector.getLayoutParams();
|
||||
if (pitchControl != null && semitoneControl != null && unhookingCheckbox != null) {
|
||||
if (getCurrentAdjustBySemitones()) {
|
||||
// replaces pitchControl slider with semitoneControl slider
|
||||
pitchControl.setVisibility(View.GONE);
|
||||
semitoneControl.setVisibility(View.VISIBLE);
|
||||
params.addRule(RelativeLayout.BELOW, R.id.semitoneControl);
|
||||
|
||||
// forces unhook for semitones
|
||||
unhookingCheckbox.setChecked(true);
|
||||
unhookingCheckbox.setEnabled(false);
|
||||
|
||||
setupTempoStepSizeSelector(rootView);
|
||||
} else {
|
||||
semitoneControl.setVisibility(View.GONE);
|
||||
pitchControl.setVisibility(View.VISIBLE);
|
||||
params.addRule(RelativeLayout.BELOW, R.id.pitchControl);
|
||||
|
||||
// (re)enables hooking selection
|
||||
unhookingCheckbox.setEnabled(true);
|
||||
setupCombinedStepSizeSelector(rootView);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void setupTempoControl(@NonNull final View rootView) {
|
||||
tempoSlider = rootView.findViewById(R.id.tempoSeekbar);
|
||||
final TextView tempoMinimumText = rootView.findViewById(R.id.tempoMinimumText);
|
||||
@ -234,23 +286,40 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
}
|
||||
}
|
||||
|
||||
private void setupSemitoneControl(@NonNull final View rootView) {
|
||||
semitoneSlider = rootView.findViewById(R.id.semitoneSeekbar);
|
||||
semitoneCurrentText = rootView.findViewById(R.id.semitoneCurrentText);
|
||||
semitoneStepDownText = rootView.findViewById(R.id.semitoneStepDown);
|
||||
semitoneStepUpText = rootView.findViewById(R.id.semitoneStepUp);
|
||||
|
||||
if (semitoneCurrentText != null) {
|
||||
semitoneCurrentText.setText(getSignedSemitonesString(semitones));
|
||||
}
|
||||
|
||||
if (semitoneSlider != null) {
|
||||
setSemitoneSlider(semitones);
|
||||
semitoneSlider.setOnSeekBarChangeListener(getOnSemitoneChangedListener());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void setupHookingControl(@NonNull final View rootView) {
|
||||
unhookingCheckbox = rootView.findViewById(R.id.unhookCheckbox);
|
||||
if (unhookingCheckbox != null) {
|
||||
// restore whether pitch and tempo are unhooked or not
|
||||
// restores whether pitch and tempo are unhooked or not
|
||||
unhookingCheckbox.setChecked(PreferenceManager
|
||||
.getDefaultSharedPreferences(requireContext())
|
||||
.getBoolean(getString(R.string.playback_unhook_key), true));
|
||||
|
||||
unhookingCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> {
|
||||
// save whether pitch and tempo are unhooked or not
|
||||
// saves whether pitch and tempo are unhooked or not
|
||||
PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
.edit()
|
||||
.putBoolean(getString(R.string.playback_unhook_key), isChecked)
|
||||
.apply();
|
||||
|
||||
if (!isChecked) {
|
||||
// when unchecked, slide back to the minimum of current tempo or pitch
|
||||
// when unchecked, slides back to the minimum of current tempo or pitch
|
||||
final double minimum = Math.min(getCurrentPitch(), getCurrentTempo());
|
||||
setSliders(minimum);
|
||||
setCurrentPlaybackParameters();
|
||||
@ -268,7 +337,51 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
}
|
||||
}
|
||||
|
||||
private void setupAdjustBySemitonesControl(@NonNull final View rootView) {
|
||||
adjustBySemitonesCheckbox = rootView.findViewById(R.id.adjustBySemitonesCheckbox);
|
||||
if (adjustBySemitonesCheckbox != null) {
|
||||
// restores whether semitone adjustment is used or not
|
||||
adjustBySemitonesCheckbox.setChecked(PreferenceManager
|
||||
.getDefaultSharedPreferences(requireContext())
|
||||
.getBoolean(getString(R.string.playback_adjust_by_semitones_key), true));
|
||||
|
||||
// stores whether semitone adjustment is used or not
|
||||
adjustBySemitonesCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> {
|
||||
PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
.edit()
|
||||
.putBoolean(getString(R.string.playback_adjust_by_semitones_key), isChecked)
|
||||
.apply();
|
||||
togglePitchSliderType(rootView);
|
||||
if (isChecked) {
|
||||
setPlaybackParameters(
|
||||
getCurrentTempo(),
|
||||
getCurrentPitch(),
|
||||
Integer.min(12,
|
||||
Integer.max(-12, percentToSemitones(getCurrentPitch())
|
||||
)),
|
||||
getCurrentSkipSilence()
|
||||
);
|
||||
setSemitoneSlider(Integer.min(12,
|
||||
Integer.max(-12, percentToSemitones(getCurrentPitch()))
|
||||
));
|
||||
} else {
|
||||
setPlaybackParameters(
|
||||
getCurrentTempo(),
|
||||
semitonesToPercent(getCurrentSemitones()),
|
||||
getCurrentSemitones(),
|
||||
getCurrentSkipSilence()
|
||||
);
|
||||
setPitchSlider(semitonesToPercent(getCurrentSemitones()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void setupStepSizeSelector(@NonNull final View rootView) {
|
||||
setStepSize(PreferenceManager
|
||||
.getDefaultSharedPreferences(requireContext())
|
||||
.getFloat(getString(R.string.adjustment_step_key), (float) DEFAULT_STEP));
|
||||
|
||||
final TextView stepSizeOnePercentText = rootView.findViewById(R.id.stepSizeOnePercent);
|
||||
final TextView stepSizeFivePercentText = rootView.findViewById(R.id.stepSizeFivePercent);
|
||||
final TextView stepSizeTenPercentText = rootView.findViewById(R.id.stepSizeTenPercent);
|
||||
@ -310,8 +423,27 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
}
|
||||
}
|
||||
|
||||
private void setupTempoStepSizeSelector(@NonNull final View rootView) {
|
||||
final TextView playbackStepTypeText = rootView.findViewById(R.id.playback_step_type);
|
||||
if (playbackStepTypeText != null) {
|
||||
playbackStepTypeText.setText(R.string.playback_tempo_step);
|
||||
}
|
||||
setupStepSizeSelector(rootView);
|
||||
}
|
||||
|
||||
private void setupCombinedStepSizeSelector(@NonNull final View rootView) {
|
||||
final TextView playbackStepTypeText = rootView.findViewById(R.id.playback_step_type);
|
||||
if (playbackStepTypeText != null) {
|
||||
playbackStepTypeText.setText(R.string.playback_step);
|
||||
}
|
||||
setupStepSizeSelector(rootView);
|
||||
}
|
||||
|
||||
private void setStepSize(final double stepSize) {
|
||||
this.stepSize = stepSize;
|
||||
PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
.edit()
|
||||
.putFloat(getString(R.string.adjustment_step_key), (float) stepSize)
|
||||
.apply();
|
||||
|
||||
if (tempoStepUpText != null) {
|
||||
tempoStepUpText.setText(getStepUpPercentString(stepSize));
|
||||
@ -344,16 +476,30 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
setCurrentPlaybackParameters();
|
||||
});
|
||||
}
|
||||
|
||||
if (semitoneStepDownText != null) {
|
||||
semitoneStepDownText.setOnClickListener(view -> {
|
||||
onSemitoneSliderUpdated(getCurrentSemitones() - 1);
|
||||
setCurrentPlaybackParameters();
|
||||
});
|
||||
}
|
||||
|
||||
if (semitoneStepUpText != null) {
|
||||
semitoneStepUpText.setOnClickListener(view -> {
|
||||
onSemitoneSliderUpdated(getCurrentSemitones() + 1);
|
||||
setCurrentPlaybackParameters();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Sliders
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private SeekBar.OnSeekBarChangeListener getOnTempoChangedListener() {
|
||||
return new SeekBar.OnSeekBarChangeListener() {
|
||||
private SimpleOnSeekBarChangeListener getOnTempoChangedListener() {
|
||||
return new SimpleOnSeekBarChangeListener() {
|
||||
@Override
|
||||
public void onProgressChanged(final SeekBar seekBar, final int progress,
|
||||
public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress,
|
||||
final boolean fromUser) {
|
||||
final double currentTempo = strategy.valueOf(progress);
|
||||
if (fromUser) {
|
||||
@ -361,23 +507,13 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
setCurrentPlaybackParameters();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStartTrackingTouch(final SeekBar seekBar) {
|
||||
// Do Nothing.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStopTrackingTouch(final SeekBar seekBar) {
|
||||
// Do Nothing.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private SeekBar.OnSeekBarChangeListener getOnPitchChangedListener() {
|
||||
return new SeekBar.OnSeekBarChangeListener() {
|
||||
private SimpleOnSeekBarChangeListener getOnPitchChangedListener() {
|
||||
return new SimpleOnSeekBarChangeListener() {
|
||||
@Override
|
||||
public void onProgressChanged(final SeekBar seekBar, final int progress,
|
||||
public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress,
|
||||
final boolean fromUser) {
|
||||
final double currentPitch = strategy.valueOf(progress);
|
||||
if (fromUser) { // this change is first in chain
|
||||
@ -385,23 +521,27 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
setCurrentPlaybackParameters();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private SimpleOnSeekBarChangeListener getOnSemitoneChangedListener() {
|
||||
return new SimpleOnSeekBarChangeListener() {
|
||||
@Override
|
||||
public void onStartTrackingTouch(final SeekBar seekBar) {
|
||||
// Do Nothing.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStopTrackingTouch(final SeekBar seekBar) {
|
||||
// Do Nothing.
|
||||
public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress,
|
||||
final boolean fromUser) {
|
||||
// semitone slider supplies values 0 to 24, subtraction by 12 is required
|
||||
final int currentSemitones = progress - 12;
|
||||
if (fromUser) { // this change is first in chain
|
||||
onSemitoneSliderUpdated(currentSemitones);
|
||||
// line below also saves semitones as pitch percentages
|
||||
onPitchSliderUpdated(semitonesToPercent(currentSemitones));
|
||||
setCurrentPlaybackParameters();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void onTempoSliderUpdated(final double newTempo) {
|
||||
if (unhookingCheckbox == null) {
|
||||
return;
|
||||
}
|
||||
if (!unhookingCheckbox.isChecked()) {
|
||||
setSliders(newTempo);
|
||||
} else {
|
||||
@ -410,9 +550,6 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
}
|
||||
|
||||
private void onPitchSliderUpdated(final double newPitch) {
|
||||
if (unhookingCheckbox == null) {
|
||||
return;
|
||||
}
|
||||
if (!unhookingCheckbox.isChecked()) {
|
||||
setSliders(newPitch);
|
||||
} else {
|
||||
@ -420,6 +557,10 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
}
|
||||
}
|
||||
|
||||
private void onSemitoneSliderUpdated(final int newSemitone) {
|
||||
setSemitoneSlider(newSemitone);
|
||||
}
|
||||
|
||||
private void setSliders(final double newValue) {
|
||||
setTempoSlider(newValue);
|
||||
setPitchSlider(newValue);
|
||||
@ -439,25 +580,49 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
pitchSlider.setProgress(strategy.progressOf(newPitch));
|
||||
}
|
||||
|
||||
private void setSemitoneSlider(final int newSemitone) {
|
||||
if (semitoneSlider == null) {
|
||||
return;
|
||||
}
|
||||
semitoneSlider.setProgress(newSemitone + 12);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Helper
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private void setCurrentPlaybackParameters() {
|
||||
setPlaybackParameters(getCurrentTempo(), getCurrentPitch(), getCurrentSkipSilence());
|
||||
if (getCurrentAdjustBySemitones()) {
|
||||
setPlaybackParameters(
|
||||
getCurrentTempo(),
|
||||
semitonesToPercent(getCurrentSemitones()),
|
||||
getCurrentSemitones(),
|
||||
getCurrentSkipSilence()
|
||||
);
|
||||
} else {
|
||||
setPlaybackParameters(
|
||||
getCurrentTempo(),
|
||||
getCurrentPitch(),
|
||||
percentToSemitones(getCurrentPitch()),
|
||||
getCurrentSkipSilence()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void setPlaybackParameters(final double newTempo, final double newPitch,
|
||||
final boolean skipSilence) {
|
||||
if (callback != null && tempoCurrentText != null && pitchCurrentText != null) {
|
||||
final int newSemitones, final boolean skipSilence) {
|
||||
if (callback != null && tempoCurrentText != null
|
||||
&& pitchCurrentText != null && semitoneCurrentText != null) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Setting playback parameters to "
|
||||
+ "tempo=[" + newTempo + "], "
|
||||
+ "pitch=[" + newPitch + "]");
|
||||
+ "pitch=[" + newPitch + "], "
|
||||
+ "semitones=[" + newSemitones + "]");
|
||||
}
|
||||
|
||||
tempoCurrentText.setText(PlayerHelper.formatSpeed(newTempo));
|
||||
pitchCurrentText.setText(PlayerHelper.formatPitch(newPitch));
|
||||
semitoneCurrentText.setText(getSignedSemitonesString(newSemitones));
|
||||
callback.onPlaybackParameterChanged((float) newTempo, (float) newPitch, skipSilence);
|
||||
}
|
||||
}
|
||||
@ -470,14 +635,19 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
return pitchSlider == null ? pitch : strategy.valueOf(pitchSlider.getProgress());
|
||||
}
|
||||
|
||||
private double getCurrentStepSize() {
|
||||
return stepSize;
|
||||
private int getCurrentSemitones() {
|
||||
// semitoneSlider is absolute, that's why - 12
|
||||
return semitoneSlider == null ? semitones : semitoneSlider.getProgress() - 12;
|
||||
}
|
||||
|
||||
private boolean getCurrentSkipSilence() {
|
||||
return skipSilenceCheckbox != null && skipSilenceCheckbox.isChecked();
|
||||
}
|
||||
|
||||
private boolean getCurrentAdjustBySemitones() {
|
||||
return adjustBySemitonesCheckbox != null && adjustBySemitonesCheckbox.isChecked();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static String getStepUpPercentString(final double percent) {
|
||||
return STEP_UP_SIGN + getPercentString(percent);
|
||||
@ -493,8 +663,21 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
return PlayerHelper.formatPitch(percent);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static String getSignedSemitonesString(final int semitones) {
|
||||
return semitones > 0 ? "+" + semitones : "" + semitones;
|
||||
}
|
||||
|
||||
public interface Callback {
|
||||
void onPlaybackParameterChanged(float playbackTempo, float playbackPitch,
|
||||
boolean playbackSkipSilence);
|
||||
}
|
||||
|
||||
public double semitonesToPercent(final int inSemitones) {
|
||||
return Math.pow(2, inSemitones / 12.0);
|
||||
}
|
||||
|
||||
public int percentToSemitones(final double inPercent) {
|
||||
return (int) Math.round(12 * Math.log(inPercent) / Math.log(2));
|
||||
}
|
||||
}
|
||||
|
@ -2,8 +2,6 @@ package org.schabi.newpipe.player.helper;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
|
||||
import com.google.android.exoplayer2.source.SingleSampleMediaSource;
|
||||
import com.google.android.exoplayer2.source.dash.DashMediaSource;
|
||||
@ -13,10 +11,13 @@ import com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistTrack
|
||||
import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource;
|
||||
import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource;
|
||||
import com.google.android.exoplayer2.upstream.DataSource;
|
||||
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
|
||||
import com.google.android.exoplayer2.upstream.DefaultDataSource;
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource;
|
||||
import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy;
|
||||
import com.google.android.exoplayer2.upstream.TransferListener;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public class PlayerDataSource {
|
||||
|
||||
public static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000;
|
||||
@ -31,14 +32,18 @@ public class PlayerDataSource {
|
||||
private static final int MANIFEST_MINIMUM_RETRY = 5;
|
||||
private static final int EXTRACTOR_MINIMUM_RETRY = Integer.MAX_VALUE;
|
||||
|
||||
private final int continueLoadingCheckIntervalBytes;
|
||||
private final DataSource.Factory cacheDataSourceFactory;
|
||||
private final DataSource.Factory cachelessDataSourceFactory;
|
||||
|
||||
public PlayerDataSource(@NonNull final Context context, @NonNull final String userAgent,
|
||||
public PlayerDataSource(@NonNull final Context context,
|
||||
@NonNull final String userAgent,
|
||||
@NonNull final TransferListener transferListener) {
|
||||
continueLoadingCheckIntervalBytes = PlayerHelper.getProgressiveLoadIntervalBytes(context);
|
||||
cacheDataSourceFactory = new CacheFactory(context, userAgent, transferListener);
|
||||
cachelessDataSourceFactory
|
||||
= new DefaultDataSourceFactory(context, userAgent, transferListener);
|
||||
cachelessDataSourceFactory = new DefaultDataSource
|
||||
.Factory(context, new DefaultHttpDataSource.Factory().setUserAgent(userAgent))
|
||||
.setTransferListener(transferListener);
|
||||
}
|
||||
|
||||
public SsMediaSource.Factory getLiveSsMediaSourceFactory() {
|
||||
@ -91,6 +96,7 @@ public class PlayerDataSource {
|
||||
|
||||
public ProgressiveMediaSource.Factory getExtractorMediaSourceFactory() {
|
||||
return new ProgressiveMediaSource.Factory(cacheDataSourceFactory)
|
||||
.setContinueLoadingCheckIntervalBytes(continueLoadingCheckIntervalBytes)
|
||||
.setLoadErrorHandlingPolicy(
|
||||
new DefaultLoadErrorHandlingPolicy(EXTRACTOR_MINIMUM_RETRY));
|
||||
}
|
||||
|
@ -34,6 +34,7 @@ import androidx.preference.PreferenceManager;
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
import com.google.android.exoplayer2.Player.RepeatMode;
|
||||
import com.google.android.exoplayer2.SeekParameters;
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
|
||||
import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection;
|
||||
import com.google.android.exoplayer2.trackselection.ExoTrackSelection;
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
|
||||
@ -77,6 +78,20 @@ public final class PlayerHelper {
|
||||
private static final NumberFormat SPEED_FORMATTER = new DecimalFormat("0.##x");
|
||||
private static final NumberFormat PITCH_FORMATTER = new DecimalFormat("##%");
|
||||
|
||||
/**
|
||||
* Maximum opacity allowed for Android 12 and higher to allow touches on other apps when using
|
||||
* NewPipe's popup player.
|
||||
*
|
||||
* <p>
|
||||
* This value is hardcoded instead of being get dynamically with the method linked of the
|
||||
* constant documentation below, because it is not static and popup player layout parameters
|
||||
* are generated with static methods.
|
||||
* </p>
|
||||
*
|
||||
* @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE
|
||||
*/
|
||||
private static final float MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER = 0.8f;
|
||||
|
||||
@Retention(SOURCE)
|
||||
@IntDef({AUTOPLAY_TYPE_ALWAYS, AUTOPLAY_TYPE_WIFI,
|
||||
AUTOPLAY_TYPE_NEVER})
|
||||
@ -143,6 +158,21 @@ public final class PlayerHelper {
|
||||
? " (" + context.getString(R.string.caption_auto_generated) + ")" : "");
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static String captionLanguageStemOf(@NonNull final String language) {
|
||||
if (!language.contains("(") || !language.contains(")")) {
|
||||
return language;
|
||||
}
|
||||
|
||||
if (language.startsWith("(")) {
|
||||
// language text is right-to-left
|
||||
final String[] parts = language.split("\\)");
|
||||
return parts[parts.length - 1].trim();
|
||||
}
|
||||
|
||||
return language.split("\\(")[0].trim();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static String resizeTypeOf(@NonNull final Context context,
|
||||
@ResizeMode final int resizeMode) {
|
||||
@ -391,6 +421,19 @@ public final class PlayerHelper {
|
||||
context.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) == 0;
|
||||
}
|
||||
|
||||
public static int getProgressiveLoadIntervalBytes(@NonNull final Context context) {
|
||||
final String preferredIntervalBytes = getPreferences(context).getString(
|
||||
context.getString(R.string.progressive_load_interval_key),
|
||||
context.getString(R.string.progressive_load_interval_default_value));
|
||||
|
||||
if (context.getString(R.string.progressive_load_interval_exoplayer_default_value)
|
||||
.equals(preferredIntervalBytes)) {
|
||||
return ProgressiveMediaSource.DEFAULT_LOADING_CHECK_INTERVAL_BYTES;
|
||||
}
|
||||
// Keeping the same KiB unit used by ProgressiveMediaSource
|
||||
return Integer.parseInt(preferredIntervalBytes) * 1024;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Private helpers
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
@ -558,6 +601,12 @@ public final class PlayerHelper {
|
||||
flags,
|
||||
PixelFormat.TRANSLUCENT);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
// Setting maximum opacity allowed for touch events to other apps for Android 12 and
|
||||
// higher to prevent non interaction when using other apps with the popup player
|
||||
closeOverlayLayoutParams.alpha = MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER;
|
||||
}
|
||||
|
||||
closeOverlayLayoutParams.gravity = Gravity.LEFT | Gravity.TOP;
|
||||
closeOverlayLayoutParams.softInputMode =
|
||||
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
|
||||
|
@ -10,7 +10,7 @@ import android.util.Log;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.google.android.exoplayer2.ExoPlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
|
||||
import org.schabi.newpipe.App;
|
||||
@ -233,9 +233,10 @@ public final class PlayerHolder {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerError(final ExoPlaybackException error) {
|
||||
public void onPlayerError(final PlaybackException error,
|
||||
final boolean isCatchableException) {
|
||||
if (listener != null) {
|
||||
listener.onPlayerError(error);
|
||||
listener.onPlayerError(error, isCatchableException);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,47 @@
|
||||
package org.schabi.newpipe.player.listeners.view
|
||||
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import org.schabi.newpipe.MainActivity
|
||||
import org.schabi.newpipe.player.Player
|
||||
import org.schabi.newpipe.player.helper.PlaybackParameterDialog
|
||||
|
||||
/**
|
||||
* Click listener for the playbackSpeed textview of the player
|
||||
*/
|
||||
class PlaybackSpeedClickListener(
|
||||
private val player: Player,
|
||||
private val playbackSpeedPopupMenu: PopupMenu
|
||||
) : View.OnClickListener {
|
||||
|
||||
companion object {
|
||||
private const val TAG: String = "PlaybSpeedClickListener"
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
if (MainActivity.DEBUG) {
|
||||
Log.d(TAG, "onPlaybackSpeedClicked() called")
|
||||
}
|
||||
|
||||
if (player.videoPlayerSelected()) {
|
||||
PlaybackParameterDialog.newInstance(
|
||||
player.playbackSpeed.toDouble(),
|
||||
player.playbackPitch.toDouble(),
|
||||
player.playbackSkipSilence
|
||||
) { speed: Float, pitch: Float, skipSilence: Boolean ->
|
||||
player.setPlaybackParameters(
|
||||
speed,
|
||||
pitch,
|
||||
skipSilence
|
||||
)
|
||||
}
|
||||
.show(player.parentActivity!!.supportFragmentManager, null)
|
||||
} else {
|
||||
playbackSpeedPopupMenu.show()
|
||||
player.isSomePopupMenuVisible = true
|
||||
}
|
||||
|
||||
player.manageControlsAfterOnClick(v)
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
package org.schabi.newpipe.player.listeners.view
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import org.schabi.newpipe.MainActivity
|
||||
import org.schabi.newpipe.extractor.MediaFormat
|
||||
import org.schabi.newpipe.player.Player
|
||||
|
||||
/**
|
||||
* Click listener for the qualityTextView of the player
|
||||
*/
|
||||
class QualityClickListener(
|
||||
private val player: Player,
|
||||
private val qualityPopupMenu: PopupMenu
|
||||
) : View.OnClickListener {
|
||||
|
||||
companion object {
|
||||
private const val TAG: String = "QualityClickListener"
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n") // we don't need I18N because of a " "
|
||||
override fun onClick(v: View) {
|
||||
if (MainActivity.DEBUG) {
|
||||
Log.d(TAG, "onQualitySelectorClicked() called")
|
||||
}
|
||||
|
||||
qualityPopupMenu.show()
|
||||
player.isSomePopupMenuVisible = true
|
||||
|
||||
val videoStream = player.selectedVideoStream
|
||||
if (videoStream != null) {
|
||||
player.binding.qualityTextView.text =
|
||||
MediaFormat.getNameById(videoStream.formatId) + " " + videoStream.resolution
|
||||
}
|
||||
|
||||
player.saveWasPlaying()
|
||||
player.manageControlsAfterOnClick(v)
|
||||
}
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
package org.schabi.newpipe.player.mediaitem;
|
||||
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* This {@link MediaItemTag} object is designed to contain metadata for a stream
|
||||
* that has failed to load. It supplies metadata from an underlying
|
||||
* {@link PlayQueueItem}, which is used by the internal players to resolve actual
|
||||
* playback info.
|
||||
*
|
||||
* This {@link MediaItemTag} does not contain any {@link StreamInfo} that can be
|
||||
* used to start playback and can be detected by checking {@link ExceptionTag#getErrors()}
|
||||
* when in generic form.
|
||||
**/
|
||||
public final class ExceptionTag implements MediaItemTag {
|
||||
@NonNull
|
||||
private final PlayQueueItem item;
|
||||
@NonNull
|
||||
private final List<Exception> errors;
|
||||
@Nullable
|
||||
private final Object extras;
|
||||
|
||||
private ExceptionTag(@NonNull final PlayQueueItem item,
|
||||
@NonNull final List<Exception> errors,
|
||||
@Nullable final Object extras) {
|
||||
this.item = item;
|
||||
this.errors = errors;
|
||||
this.extras = extras;
|
||||
}
|
||||
|
||||
public static ExceptionTag of(@NonNull final PlayQueueItem playQueueItem,
|
||||
@NonNull final List<Exception> errors) {
|
||||
return new ExceptionTag(playQueueItem, errors, null);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public List<Exception> getErrors() {
|
||||
return errors;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getServiceId() {
|
||||
return item.getServiceId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTitle() {
|
||||
return item.getTitle();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUploaderName() {
|
||||
return item.getUploader();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getDurationSeconds() {
|
||||
return item.getDuration();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getStreamUrl() {
|
||||
return item.getUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getThumbnailUrl() {
|
||||
return item.getThumbnailUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUploaderUrl() {
|
||||
return item.getUploaderUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StreamType getStreamType() {
|
||||
return item.getStreamType();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> Optional<T> getMaybeExtras(@NonNull final Class<T> type) {
|
||||
return Optional.ofNullable(extras).map(type::cast);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> MediaItemTag withExtras(@NonNull final T extra) {
|
||||
return new ExceptionTag(item, errors, extra);
|
||||
}
|
||||
}
|
@ -0,0 +1,127 @@
|
||||
package org.schabi.newpipe.player.mediaitem;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.MediaMetadata;
|
||||
import com.google.android.exoplayer2.Player;
|
||||
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.extractor.stream.VideoStream;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Metadata container and accessor used by player internals.
|
||||
*
|
||||
* This interface ensures consistency of fetching metadata on each stream,
|
||||
* which is encapsulated in a {@link MediaItem} and delivered via ExoPlayer's
|
||||
* {@link Player.Listener} on event triggers to the downstream users.
|
||||
**/
|
||||
public interface MediaItemTag {
|
||||
|
||||
List<Exception> getErrors();
|
||||
|
||||
int getServiceId();
|
||||
|
||||
String getTitle();
|
||||
|
||||
String getUploaderName();
|
||||
|
||||
long getDurationSeconds();
|
||||
|
||||
String getStreamUrl();
|
||||
|
||||
String getThumbnailUrl();
|
||||
|
||||
String getUploaderUrl();
|
||||
|
||||
StreamType getStreamType();
|
||||
|
||||
@NonNull
|
||||
default Optional<StreamInfo> getMaybeStreamInfo() {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
default Optional<Quality> getMaybeQuality() {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
<T> Optional<T> getMaybeExtras(@NonNull Class<T> type);
|
||||
|
||||
<T> MediaItemTag withExtras(@NonNull T extra);
|
||||
|
||||
@NonNull
|
||||
static Optional<MediaItemTag> from(@Nullable final MediaItem mediaItem) {
|
||||
if (mediaItem == null || mediaItem.localConfiguration == null
|
||||
|| !(mediaItem.localConfiguration.tag instanceof MediaItemTag)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of((MediaItemTag) mediaItem.localConfiguration.tag);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
default String makeMediaId() {
|
||||
return UUID.randomUUID().toString() + "[" + getTitle() + "]";
|
||||
}
|
||||
|
||||
@NonNull
|
||||
default MediaItem asMediaItem() {
|
||||
final MediaMetadata mediaMetadata = new MediaMetadata.Builder()
|
||||
.setMediaUri(Uri.parse(getStreamUrl()))
|
||||
.setArtworkUri(Uri.parse(getThumbnailUrl()))
|
||||
.setArtist(getUploaderName())
|
||||
.setDescription(getTitle())
|
||||
.setDisplayTitle(getTitle())
|
||||
.setTitle(getTitle())
|
||||
.build();
|
||||
|
||||
return MediaItem.fromUri(getStreamUrl())
|
||||
.buildUpon()
|
||||
.setMediaId(makeMediaId())
|
||||
.setMediaMetadata(mediaMetadata)
|
||||
.setTag(this)
|
||||
.build();
|
||||
}
|
||||
|
||||
final class Quality {
|
||||
@NonNull
|
||||
private final List<VideoStream> sortedVideoStreams;
|
||||
private final int selectedVideoStreamIndex;
|
||||
|
||||
private Quality(@NonNull final List<VideoStream> sortedVideoStreams,
|
||||
final int selectedVideoStreamIndex) {
|
||||
this.sortedVideoStreams = sortedVideoStreams;
|
||||
this.selectedVideoStreamIndex = selectedVideoStreamIndex;
|
||||
}
|
||||
|
||||
static Quality of(@NonNull final List<VideoStream> sortedVideoStreams,
|
||||
final int selectedVideoStreamIndex) {
|
||||
return new Quality(sortedVideoStreams, selectedVideoStreamIndex);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public List<VideoStream> getSortedVideoStreams() {
|
||||
return sortedVideoStreams;
|
||||
}
|
||||
|
||||
public int getSelectedVideoStreamIndex() {
|
||||
return selectedVideoStreamIndex;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public VideoStream getSelectedVideoStream() {
|
||||
return selectedVideoStreamIndex < 0
|
||||
|| selectedVideoStreamIndex >= sortedVideoStreams.size()
|
||||
? null : sortedVideoStreams.get(selectedVideoStreamIndex);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,85 @@
|
||||
package org.schabi.newpipe.player.mediaitem;
|
||||
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* This is a Placeholding {@link MediaItemTag}, designed as a dummy metadata object for
|
||||
* any stream that has not been resolved.
|
||||
*
|
||||
* This object cannot be instantiated and does not hold real metadata of any form.
|
||||
* */
|
||||
public final class PlaceholderTag implements MediaItemTag {
|
||||
public static final PlaceholderTag EMPTY = new PlaceholderTag(null);
|
||||
private static final String UNKNOWN_VALUE_INTERNAL = "Placeholder";
|
||||
|
||||
@Nullable
|
||||
private final Object extras;
|
||||
|
||||
private PlaceholderTag(@Nullable final Object extras) {
|
||||
this.extras = extras;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public List<Exception> getErrors() {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getServiceId() {
|
||||
return Constants.NO_SERVICE_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTitle() {
|
||||
return UNKNOWN_VALUE_INTERNAL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUploaderName() {
|
||||
return UNKNOWN_VALUE_INTERNAL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getDurationSeconds() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getStreamUrl() {
|
||||
return UNKNOWN_VALUE_INTERNAL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getThumbnailUrl() {
|
||||
return UNKNOWN_VALUE_INTERNAL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUploaderUrl() {
|
||||
return UNKNOWN_VALUE_INTERNAL;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StreamType getStreamType() {
|
||||
return StreamType.NONE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> Optional<T> getMaybeExtras(@NonNull final Class<T> type) {
|
||||
return Optional.ofNullable(extras).map(type::cast);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> MediaItemTag withExtras(@NonNull final T extra) {
|
||||
return new PlaceholderTag(extra);
|
||||
}
|
||||
}
|
@ -0,0 +1,115 @@
|
||||
package org.schabi.newpipe.player.mediaitem;
|
||||
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.extractor.stream.VideoStream;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* This {@link MediaItemTag} object contains metadata for a resolved stream
|
||||
* that is ready for playback. This object guarantees the {@link StreamInfo}
|
||||
* is available and may provide the {@link Quality} of video stream used in
|
||||
* the {@link MediaItem}.
|
||||
**/
|
||||
public final class StreamInfoTag implements MediaItemTag {
|
||||
@NonNull
|
||||
private final StreamInfo streamInfo;
|
||||
@Nullable
|
||||
private final MediaItemTag.Quality quality;
|
||||
@Nullable
|
||||
private final Object extras;
|
||||
|
||||
private StreamInfoTag(@NonNull final StreamInfo streamInfo,
|
||||
@Nullable final MediaItemTag.Quality quality,
|
||||
@Nullable final Object extras) {
|
||||
this.streamInfo = streamInfo;
|
||||
this.quality = quality;
|
||||
this.extras = extras;
|
||||
}
|
||||
|
||||
public static StreamInfoTag of(@NonNull final StreamInfo streamInfo,
|
||||
@NonNull final List<VideoStream> sortedVideoStreams,
|
||||
final int selectedVideoStreamIndex) {
|
||||
final Quality quality = Quality.of(sortedVideoStreams, selectedVideoStreamIndex);
|
||||
return new StreamInfoTag(streamInfo, quality, null);
|
||||
}
|
||||
|
||||
public static StreamInfoTag of(@NonNull final StreamInfo streamInfo) {
|
||||
return new StreamInfoTag(streamInfo, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Exception> getErrors() {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getServiceId() {
|
||||
return streamInfo.getServiceId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTitle() {
|
||||
return streamInfo.getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUploaderName() {
|
||||
return streamInfo.getUploaderName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getDurationSeconds() {
|
||||
return streamInfo.getDuration();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getStreamUrl() {
|
||||
return streamInfo.getUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getThumbnailUrl() {
|
||||
return streamInfo.getThumbnailUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUploaderUrl() {
|
||||
return streamInfo.getUploaderUrl();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StreamType getStreamType() {
|
||||
return streamInfo.getStreamType();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Optional<StreamInfo> getMaybeStreamInfo() {
|
||||
return Optional.of(streamInfo);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Optional<Quality> getMaybeQuality() {
|
||||
return Optional.ofNullable(quality);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> Optional<T> getMaybeExtras(@NonNull final Class<T> type) {
|
||||
return Optional.ofNullable(extras).map(type::cast);
|
||||
}
|
||||
|
||||
@Override
|
||||
public StreamInfoTag withExtras(@NonNull final Object extra) {
|
||||
return new StreamInfoTag(streamInfo, quality, extra);
|
||||
}
|
||||
}
|
@ -7,7 +7,6 @@ import android.support.v4.media.session.MediaSessionCompat;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.exoplayer2.ControlDispatcher;
|
||||
import com.google.android.exoplayer2.Player;
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||
import com.google.android.exoplayer2.util.Util;
|
||||
@ -44,17 +43,17 @@ public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTimelineChanged(final Player player) {
|
||||
public void onTimelineChanged(@NonNull final Player player) {
|
||||
publishFloatingQueueWindow();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCurrentWindowIndexChanged(final Player player) {
|
||||
public void onCurrentMediaItemIndexChanged(@NonNull final Player player) {
|
||||
if (activeQueueItemId == MediaSessionCompat.QueueItem.UNKNOWN_ID
|
||||
|| player.getCurrentTimeline().getWindowCount() > maxQueueSize) {
|
||||
publishFloatingQueueWindow();
|
||||
} else if (!player.getCurrentTimeline().isEmpty()) {
|
||||
activeQueueItemId = player.getCurrentWindowIndex();
|
||||
activeQueueItemId = player.getCurrentMediaItemIndex();
|
||||
}
|
||||
}
|
||||
|
||||
@ -64,18 +63,17 @@ public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSkipToPrevious(final Player player, final ControlDispatcher controlDispatcher) {
|
||||
public void onSkipToPrevious(@NonNull final Player player) {
|
||||
callback.playPrevious();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSkipToQueueItem(final Player player, final ControlDispatcher controlDispatcher,
|
||||
final long id) {
|
||||
public void onSkipToQueueItem(@NonNull final Player player, final long id) {
|
||||
callback.playItemAtIndex((int) id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSkipToNext(final Player player, final ControlDispatcher controlDispatcher) {
|
||||
public void onSkipToNext(@NonNull final Player player) {
|
||||
callback.playNext();
|
||||
}
|
||||
|
||||
@ -102,8 +100,10 @@ public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(final Player player, final ControlDispatcher controlDispatcher,
|
||||
final String command, final Bundle extras, final ResultReceiver cb) {
|
||||
public boolean onCommand(@NonNull final Player player,
|
||||
@NonNull final String command,
|
||||
@Nullable final Bundle extras,
|
||||
@Nullable final ResultReceiver cb) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -1,23 +0,0 @@
|
||||
package org.schabi.newpipe.player.mediasession;
|
||||
|
||||
import com.google.android.exoplayer2.DefaultControlDispatcher;
|
||||
import com.google.android.exoplayer2.Player;
|
||||
|
||||
public class PlayQueuePlaybackController extends DefaultControlDispatcher {
|
||||
private final MediaSessionCallback callback;
|
||||
|
||||
public PlayQueuePlaybackController(final MediaSessionCallback callback) {
|
||||
super();
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchSetPlayWhenReady(final Player player, final boolean playWhenReady) {
|
||||
if (playWhenReady) {
|
||||
callback.play();
|
||||
} else {
|
||||
callback.pause();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
@ -2,52 +2,83 @@ package org.schabi.newpipe.player.mediasource;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
import com.google.android.exoplayer2.Timeline;
|
||||
import com.google.android.exoplayer2.source.BaseMediaSource;
|
||||
import com.google.android.exoplayer2.source.MediaPeriod;
|
||||
import com.google.android.exoplayer2.source.SilenceMediaSource;
|
||||
import com.google.android.exoplayer2.source.SinglePeriodTimeline;
|
||||
import com.google.android.exoplayer2.upstream.Allocator;
|
||||
import com.google.android.exoplayer2.upstream.TransferListener;
|
||||
|
||||
import org.schabi.newpipe.player.mediaitem.ExceptionTag;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public class FailedMediaSource extends BaseMediaSource implements ManagedMediaSource {
|
||||
/**
|
||||
* Play 2 seconds of silenced audio when a stream fails to resolve due to a known issue,
|
||||
* such as {@link org.schabi.newpipe.extractor.exceptions.ExtractionException}.
|
||||
*
|
||||
* This silence duration allows user to react and have time to jump to a previous stream,
|
||||
* while still provide a smooth playback experience. A duration lower than 1 second is
|
||||
* not recommended, it may cause ExoPlayer to buffer for a while.
|
||||
* */
|
||||
public static final long SILENCE_DURATION_US = TimeUnit.SECONDS.toMicros(2);
|
||||
public static final MediaPeriod SILENT_MEDIA = makeSilentMediaPeriod(SILENCE_DURATION_US);
|
||||
|
||||
private final String TAG = "FailedMediaSource@" + Integer.toHexString(hashCode());
|
||||
private final PlayQueueItem playQueueItem;
|
||||
private final FailedMediaSourceException error;
|
||||
private final Exception error;
|
||||
private final long retryTimestamp;
|
||||
|
||||
private final MediaItem mediaItem;
|
||||
/**
|
||||
* Fail the play queue item associated with this source, with potential future retries.
|
||||
*
|
||||
* The error will be propagated if the cause for load exception is unspecified.
|
||||
* This means the error might be caused by reasons outside of extraction (e.g. no network).
|
||||
* Otherwise, a silenced stream will play instead.
|
||||
*
|
||||
* @param playQueueItem play queue item
|
||||
* @param error exception that was the reason to fail
|
||||
* @param retryTimestamp epoch timestamp when this MediaSource can be refreshed
|
||||
*/
|
||||
public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem,
|
||||
@NonNull final FailedMediaSourceException error,
|
||||
@NonNull final Exception error,
|
||||
final long retryTimestamp) {
|
||||
this.playQueueItem = playQueueItem;
|
||||
this.error = error;
|
||||
this.retryTimestamp = retryTimestamp;
|
||||
this.mediaItem = ExceptionTag
|
||||
.of(playQueueItem, Collections.singletonList(error))
|
||||
.withExtras(this)
|
||||
.asMediaItem();
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently fail the play queue item associated with this source, with no hope of retrying.
|
||||
* The error will always be propagated to ExoPlayer.
|
||||
*
|
||||
* @param playQueueItem play queue item
|
||||
* @param error exception that was the reason to fail
|
||||
*/
|
||||
public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem,
|
||||
@NonNull final FailedMediaSourceException error) {
|
||||
this.playQueueItem = playQueueItem;
|
||||
this.error = error;
|
||||
this.retryTimestamp = Long.MAX_VALUE;
|
||||
public static FailedMediaSource of(@NonNull final PlayQueueItem playQueueItem,
|
||||
@NonNull final FailedMediaSourceException error) {
|
||||
return new FailedMediaSource(playQueueItem, error, Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
public static FailedMediaSource of(@NonNull final PlayQueueItem playQueueItem,
|
||||
@NonNull final Exception error,
|
||||
final long retryWaitMillis) {
|
||||
return new FailedMediaSource(playQueueItem, error,
|
||||
System.currentTimeMillis() + retryWaitMillis);
|
||||
}
|
||||
|
||||
public PlayQueueItem getStream() {
|
||||
return playQueueItem;
|
||||
}
|
||||
|
||||
public FailedMediaSourceException getError() {
|
||||
public Exception getError() {
|
||||
return error;
|
||||
}
|
||||
|
||||
@ -55,35 +86,78 @@ public class FailedMediaSource extends BaseMediaSource implements ManagedMediaSo
|
||||
return System.currentTimeMillis() >= retryTimestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@link MediaItem} whose media is provided by the source.
|
||||
*/
|
||||
@Override
|
||||
public MediaItem getMediaItem() {
|
||||
return MediaItem.fromUri(playQueueItem.getUrl());
|
||||
return mediaItem;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void maybeThrowSourceInfoRefreshError() throws IOException {
|
||||
throw new IOException(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator,
|
||||
final long startPositionUs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void releasePeriod(final MediaPeriod mediaPeriod) { }
|
||||
|
||||
/**
|
||||
* Prepares the source with {@link Timeline} info on the silence playback when the error
|
||||
* is classed as {@link FailedMediaSourceException}, for example, when the error is
|
||||
* {@link org.schabi.newpipe.extractor.exceptions.ExtractionException ExtractionException}.
|
||||
* These types of error are swallowed by {@link FailedMediaSource}, and the underlying
|
||||
* exception is carried to the {@link MediaItem} metadata during playback.
|
||||
* <br><br>
|
||||
* If the exception is not known, e.g. {@link java.net.UnknownHostException} or some
|
||||
* other network issue, then no source info is refreshed and
|
||||
* {@link #maybeThrowSourceInfoRefreshError()} be will triggered.
|
||||
* <br><br>
|
||||
* Note that this method is called only once until {@link #releaseSourceInternal()} is called,
|
||||
* so if no action is done in here, playback will stall unless
|
||||
* {@link #maybeThrowSourceInfoRefreshError()} is called.
|
||||
*
|
||||
* @param mediaTransferListener No data transfer listener needed, ignored here.
|
||||
*/
|
||||
@Override
|
||||
protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) {
|
||||
Log.e(TAG, "Loading failed source: ", error);
|
||||
if (error instanceof FailedMediaSourceException) {
|
||||
refreshSourceInfo(makeSilentMediaTimeline(SILENCE_DURATION_US, mediaItem));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the error is not known, e.g. network issue, then the exception is not swallowed here in
|
||||
* {@link FailedMediaSource}. The exception is then propagated to the player, which
|
||||
* {@link org.schabi.newpipe.player.Player Player} can react to inside
|
||||
* {@link com.google.android.exoplayer2.Player.Listener#onPlayerError(PlaybackException)}.
|
||||
*
|
||||
* @throws IOException An error which will always result in
|
||||
* {@link com.google.android.exoplayer2.PlaybackException#ERROR_CODE_IO_UNSPECIFIED}.
|
||||
*/
|
||||
@Override
|
||||
public void maybeThrowSourceInfoRefreshError() throws IOException {
|
||||
if (!(error instanceof FailedMediaSourceException)) {
|
||||
throw new IOException(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is only called if {@link #prepareSourceInternal(TransferListener)}
|
||||
* refreshes the source info with no exception. All parameters are ignored as this
|
||||
* returns a static and reused piece of silent audio.
|
||||
*
|
||||
* @param id The identifier of the period.
|
||||
* @param allocator An {@link Allocator} from which to obtain media buffer allocations.
|
||||
* @param startPositionUs The expected start position, in microseconds.
|
||||
* @return The common {@link MediaPeriod} holding the silence.
|
||||
*/
|
||||
@Override
|
||||
public MediaPeriod createPeriod(final MediaPeriodId id,
|
||||
final Allocator allocator,
|
||||
final long startPositionUs) {
|
||||
return SILENT_MEDIA;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void releaseSourceInternal() { }
|
||||
public void releasePeriod(final MediaPeriod mediaPeriod) {
|
||||
/* Do Nothing (we want to keep re-using the Silent MediaPeriod) */
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void releaseSourceInternal() {
|
||||
/* Do Nothing, no clean-up for processing/extra thread is needed by this MediaSource */
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity,
|
||||
@ -117,4 +191,22 @@ public class FailedMediaSource extends BaseMediaSource implements ManagedMediaSo
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
|
||||
private static Timeline makeSilentMediaTimeline(final long durationUs,
|
||||
@NonNull final MediaItem mediaItem) {
|
||||
return new SinglePeriodTimeline(
|
||||
durationUs,
|
||||
/* isSeekable= */ true,
|
||||
/* isDynamic= */ false,
|
||||
/* useLiveConfiguration= */ false,
|
||||
/* manifest= */ null,
|
||||
mediaItem);
|
||||
}
|
||||
|
||||
private static MediaPeriod makeSilentMediaPeriod(final long durationUs) {
|
||||
return new SilenceMediaSource.Factory()
|
||||
.setDurationUs(durationUs)
|
||||
.createMediaSource()
|
||||
.createPeriod(null, null, 0);
|
||||
}
|
||||
}
|
||||
|
@ -1,32 +1,46 @@
|
||||
package org.schabi.newpipe.player.mediasource;
|
||||
|
||||
import android.os.Handler;
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.Timeline;
|
||||
import com.google.android.exoplayer2.source.CompositeMediaSource;
|
||||
import com.google.android.exoplayer2.source.MediaPeriod;
|
||||
import com.google.android.exoplayer2.source.MediaSource;
|
||||
import com.google.android.exoplayer2.upstream.Allocator;
|
||||
import com.google.android.exoplayer2.upstream.TransferListener;
|
||||
|
||||
import org.schabi.newpipe.player.mediaitem.MediaItemTag;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.drm.DrmSessionEventListener;
|
||||
import com.google.android.exoplayer2.source.MediaPeriod;
|
||||
import com.google.android.exoplayer2.source.MediaSource;
|
||||
import com.google.android.exoplayer2.source.MediaSourceEventListener;
|
||||
import com.google.android.exoplayer2.upstream.Allocator;
|
||||
import com.google.android.exoplayer2.upstream.TransferListener;
|
||||
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class LoadedMediaSource implements ManagedMediaSource {
|
||||
public class LoadedMediaSource extends CompositeMediaSource<Integer> implements ManagedMediaSource {
|
||||
private final MediaSource source;
|
||||
private final PlayQueueItem stream;
|
||||
private final MediaItem mediaItem;
|
||||
private final long expireTimestamp;
|
||||
|
||||
public LoadedMediaSource(@NonNull final MediaSource source, @NonNull final PlayQueueItem stream,
|
||||
/**
|
||||
* Uses a {@link CompositeMediaSource} to wrap one or more child {@link MediaSource}s
|
||||
* containing actual media. This wrapper {@link LoadedMediaSource} holds the expiration
|
||||
* timestamp as a {@link ManagedMediaSource} to allow explicit playlist management under
|
||||
* {@link ManagedMediaSourcePlaylist}.
|
||||
*
|
||||
* @param source The child media source with actual media.
|
||||
* @param tag Metadata for the child media source.
|
||||
* @param stream The queue item associated with the media source.
|
||||
* @param expireTimestamp The timestamp when the media source expires and might not be
|
||||
* available for playback.
|
||||
*/
|
||||
public LoadedMediaSource(@NonNull final MediaSource source,
|
||||
@NonNull final MediaItemTag tag,
|
||||
@NonNull final PlayQueueItem stream,
|
||||
final long expireTimestamp) {
|
||||
this.source = source;
|
||||
this.stream = stream;
|
||||
this.expireTimestamp = expireTimestamp;
|
||||
|
||||
this.mediaItem = tag.withExtras(this).asMediaItem();
|
||||
}
|
||||
|
||||
public PlayQueueItem getStream() {
|
||||
@ -37,20 +51,38 @@ public class LoadedMediaSource implements ManagedMediaSource {
|
||||
return System.currentTimeMillis() >= expireTimestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegates the preparation of child {@link MediaSource}s to the
|
||||
* {@link CompositeMediaSource} wrapper. Since all {@link LoadedMediaSource}s use only
|
||||
* a single child media, the child id of 0 is always used (sonar doesn't like null as id here).
|
||||
*
|
||||
* @param mediaTransferListener A data transfer listener that will be registered by the
|
||||
* {@link CompositeMediaSource} for child source preparation.
|
||||
*/
|
||||
@Override
|
||||
public void prepareSource(final MediaSourceCaller mediaSourceCaller,
|
||||
@Nullable final TransferListener mediaTransferListener) {
|
||||
source.prepareSource(mediaSourceCaller, mediaTransferListener);
|
||||
protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) {
|
||||
super.prepareSourceInternal(mediaTransferListener);
|
||||
prepareChildSource(0, source);
|
||||
}
|
||||
|
||||
/**
|
||||
* When any child {@link MediaSource} is prepared, the refreshed {@link Timeline} can
|
||||
* be listened to here. But since {@link LoadedMediaSource} has only a single child source,
|
||||
* this method is called only once until {@link #releaseSourceInternal()} is called.
|
||||
* <br><br>
|
||||
* On refresh, the {@link CompositeMediaSource} delegate will be notified with the
|
||||
* new {@link Timeline}, otherwise {@link #createPeriod(MediaPeriodId, Allocator, long)}
|
||||
* will not be called and playback may be stalled.
|
||||
*
|
||||
* @param id The unique id used to prepare the child source.
|
||||
* @param mediaSource The child source whose source info has been refreshed.
|
||||
* @param timeline The new timeline of the child source.
|
||||
*/
|
||||
@Override
|
||||
public void maybeThrowSourceInfoRefreshError() throws IOException {
|
||||
source.maybeThrowSourceInfoRefreshError();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void enable(final MediaSourceCaller caller) {
|
||||
source.enable(caller);
|
||||
protected void onChildSourceInfoRefreshed(final Integer id,
|
||||
final MediaSource mediaSource,
|
||||
final Timeline timeline) {
|
||||
refreshSourceInfo(timeline);
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -64,57 +96,10 @@ public class LoadedMediaSource implements ManagedMediaSource {
|
||||
source.releasePeriod(mediaPeriod);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disable(final MediaSourceCaller caller) {
|
||||
source.disable(caller);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void releaseSource(final MediaSourceCaller mediaSourceCaller) {
|
||||
source.releaseSource(mediaSourceCaller);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addEventListener(final Handler handler,
|
||||
final MediaSourceEventListener eventListener) {
|
||||
source.addEventListener(handler, eventListener);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeEventListener(final MediaSourceEventListener eventListener) {
|
||||
source.removeEventListener(eventListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a {@link DrmSessionEventListener} to the list of listeners which are notified of DRM
|
||||
* events for this media source.
|
||||
*
|
||||
* @param handler A handler on the which listener events will be posted.
|
||||
* @param eventListener The listener to be added.
|
||||
*/
|
||||
@Override
|
||||
public void addDrmEventListener(final Handler handler,
|
||||
final DrmSessionEventListener eventListener) {
|
||||
source.addDrmEventListener(handler, eventListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a {@link DrmSessionEventListener} from the list of listeners which are notified of
|
||||
* DRM events for this media source.
|
||||
*
|
||||
* @param eventListener The listener to be removed.
|
||||
*/
|
||||
@Override
|
||||
public void removeDrmEventListener(final DrmSessionEventListener eventListener) {
|
||||
source.removeDrmEventListener(eventListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the {@link MediaItem} whose media is provided by the source.
|
||||
*/
|
||||
@NonNull
|
||||
@Override
|
||||
public MediaItem getMediaItem() {
|
||||
return source.getMediaItem();
|
||||
return mediaItem;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -1,7 +1,6 @@
|
||||
package org.schabi.newpipe.player.mediasource;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.exoplayer2.source.MediaSource;
|
||||
|
||||
@ -28,10 +27,4 @@ public interface ManagedMediaSource extends MediaSource {
|
||||
* @return whether this source is for the specified stream
|
||||
*/
|
||||
boolean isStreamEqual(@NonNull PlayQueueItem stream);
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
default Object getTag() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,8 @@ import androidx.annotation.Nullable;
|
||||
import com.google.android.exoplayer2.source.ConcatenatingMediaSource;
|
||||
import com.google.android.exoplayer2.source.ShuffleOrder;
|
||||
|
||||
import org.schabi.newpipe.player.mediaitem.MediaItemTag;
|
||||
|
||||
public class ManagedMediaSourcePlaylist {
|
||||
@NonNull
|
||||
private final ConcatenatingMediaSource internalSource;
|
||||
@ -34,8 +36,14 @@ public class ManagedMediaSourcePlaylist {
|
||||
*/
|
||||
@Nullable
|
||||
public ManagedMediaSource get(final int index) {
|
||||
return (index < 0 || index >= size())
|
||||
? null : (ManagedMediaSource) internalSource.getMediaSource(index).getTag();
|
||||
if (index < 0 || index >= size()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return MediaItemTag
|
||||
.from(internalSource.getMediaSource(index).getMediaItem())
|
||||
.flatMap(tag -> tag.getMaybeExtras(ManagedMediaSource.class))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@ -54,7 +62,7 @@ public class ManagedMediaSourcePlaylist {
|
||||
* @see #append(ManagedMediaSource)
|
||||
*/
|
||||
public synchronized void expand() {
|
||||
append(new PlaceholderMediaSource());
|
||||
append(PlaceholderMediaSource.COPY);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -115,10 +123,10 @@ public class ManagedMediaSourcePlaylist {
|
||||
public synchronized void invalidate(final int index,
|
||||
@Nullable final Handler handler,
|
||||
@Nullable final Runnable finalizingAction) {
|
||||
if (get(index) instanceof PlaceholderMediaSource) {
|
||||
if (get(index) == PlaceholderMediaSource.COPY) {
|
||||
return;
|
||||
}
|
||||
update(index, new PlaceholderMediaSource(), handler, finalizingAction);
|
||||
update(index, PlaceholderMediaSource.COPY, handler, finalizingAction);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,28 +1,35 @@
|
||||
package org.schabi.newpipe.player.mediasource;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.exoplayer2.MediaItem;
|
||||
import com.google.android.exoplayer2.source.BaseMediaSource;
|
||||
import com.google.android.exoplayer2.Timeline;
|
||||
import com.google.android.exoplayer2.source.CompositeMediaSource;
|
||||
import com.google.android.exoplayer2.source.MediaPeriod;
|
||||
import com.google.android.exoplayer2.source.MediaSource;
|
||||
import com.google.android.exoplayer2.upstream.Allocator;
|
||||
import com.google.android.exoplayer2.upstream.TransferListener;
|
||||
|
||||
import org.schabi.newpipe.player.mediaitem.PlaceholderTag;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
|
||||
public class PlaceholderMediaSource extends BaseMediaSource implements ManagedMediaSource {
|
||||
/**
|
||||
* Returns the {@link MediaItem} whose media is provided by the source.
|
||||
*/
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
final class PlaceholderMediaSource
|
||||
extends CompositeMediaSource<Void> implements ManagedMediaSource {
|
||||
public static final PlaceholderMediaSource COPY = new PlaceholderMediaSource();
|
||||
private static final MediaItem MEDIA_ITEM = PlaceholderTag.EMPTY.withExtras(COPY).asMediaItem();
|
||||
|
||||
private PlaceholderMediaSource() { }
|
||||
|
||||
@Override
|
||||
public MediaItem getMediaItem() {
|
||||
return null;
|
||||
return MEDIA_ITEM;
|
||||
}
|
||||
|
||||
// Do nothing, so this will stall the playback
|
||||
@Override
|
||||
public void maybeThrowSourceInfoRefreshError() { }
|
||||
protected void onChildSourceInfoRefreshed(final Void id,
|
||||
final MediaSource mediaSource,
|
||||
final Timeline timeline) {
|
||||
/* Do nothing, no timeline updates or error will stall playback */
|
||||
}
|
||||
|
||||
@Override
|
||||
public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator,
|
||||
@ -33,12 +40,6 @@ public class PlaceholderMediaSource extends BaseMediaSource implements ManagedMe
|
||||
@Override
|
||||
public void releasePeriod(final MediaPeriod mediaPeriod) { }
|
||||
|
||||
@Override
|
||||
protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) { }
|
||||
|
||||
@Override
|
||||
protected void releaseSourceInternal() { }
|
||||
|
||||
@Override
|
||||
public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity,
|
||||
final boolean isInterruptable) {
|
||||
|
@ -1,92 +0,0 @@
|
||||
package org.schabi.newpipe.player.playback;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Pair;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.exoplayer2.C;
|
||||
import com.google.android.exoplayer2.Format;
|
||||
import com.google.android.exoplayer2.RendererCapabilities.Capabilities;
|
||||
import com.google.android.exoplayer2.source.TrackGroup;
|
||||
import com.google.android.exoplayer2.source.TrackGroupArray;
|
||||
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
|
||||
import com.google.android.exoplayer2.trackselection.ExoTrackSelection;
|
||||
import com.google.android.exoplayer2.util.Assertions;
|
||||
|
||||
/**
|
||||
* This class allows irregular text language labels for use when selecting text captions and
|
||||
* is mostly a copy-paste from {@link DefaultTrackSelector}.
|
||||
* <p>
|
||||
* This is a hack and should be removed once ExoPlayer fixes language normalization to accept
|
||||
* a broader set of languages.
|
||||
* </p>
|
||||
*/
|
||||
public class CustomTrackSelector extends DefaultTrackSelector {
|
||||
private String preferredTextLanguage;
|
||||
|
||||
public CustomTrackSelector(final Context context,
|
||||
final ExoTrackSelection.Factory adaptiveTrackSelectionFactory) {
|
||||
super(context, adaptiveTrackSelectionFactory);
|
||||
}
|
||||
|
||||
private static boolean formatHasLanguage(final Format format, final String language) {
|
||||
return language != null && TextUtils.equals(language, format.language);
|
||||
}
|
||||
|
||||
public String getPreferredTextLanguage() {
|
||||
return preferredTextLanguage;
|
||||
}
|
||||
|
||||
public void setPreferredTextLanguage(@NonNull final String label) {
|
||||
Assertions.checkNotNull(label);
|
||||
if (!label.equals(preferredTextLanguage)) {
|
||||
preferredTextLanguage = label;
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
protected Pair<ExoTrackSelection.Definition, TextTrackScore> selectTextTrack(
|
||||
final TrackGroupArray groups,
|
||||
@NonNull final int[][] formatSupport,
|
||||
@NonNull final Parameters params,
|
||||
@Nullable final String selectedAudioLanguage) {
|
||||
TrackGroup selectedGroup = null;
|
||||
int selectedTrackIndex = C.INDEX_UNSET;
|
||||
TextTrackScore selectedTrackScore = null;
|
||||
|
||||
for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) {
|
||||
final TrackGroup trackGroup = groups.get(groupIndex);
|
||||
@Capabilities final int[] trackFormatSupport = formatSupport[groupIndex];
|
||||
|
||||
for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) {
|
||||
if (isSupported(trackFormatSupport[trackIndex],
|
||||
params.exceedRendererCapabilitiesIfNecessary)) {
|
||||
final Format format = trackGroup.getFormat(trackIndex);
|
||||
final TextTrackScore trackScore = new TextTrackScore(format, params,
|
||||
trackFormatSupport[trackIndex], selectedAudioLanguage);
|
||||
|
||||
if (formatHasLanguage(format, preferredTextLanguage)) {
|
||||
selectedGroup = trackGroup;
|
||||
selectedTrackIndex = trackIndex;
|
||||
selectedTrackScore = trackScore;
|
||||
break; // found user selected match (perfect!)
|
||||
|
||||
} else if (trackScore.isWithinConstraints && (selectedTrackScore == null
|
||||
|| trackScore.compareTo(selectedTrackScore) > 0)) {
|
||||
selectedGroup = trackGroup;
|
||||
selectedTrackIndex = trackIndex;
|
||||
selectedTrackScore = trackScore;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return selectedGroup == null ? null
|
||||
: Pair.create(new ExoTrackSelection.Definition(selectedGroup, selectedTrackIndex),
|
||||
Assertions.checkNotNull(selectedTrackScore));
|
||||
}
|
||||
}
|
@ -11,11 +11,12 @@ import com.google.android.exoplayer2.source.MediaSource;
|
||||
|
||||
import org.reactivestreams.Subscriber;
|
||||
import org.reactivestreams.Subscription;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.player.mediaitem.MediaItemTag;
|
||||
import org.schabi.newpipe.player.mediasource.FailedMediaSource;
|
||||
import org.schabi.newpipe.player.mediasource.LoadedMediaSource;
|
||||
import org.schabi.newpipe.player.mediasource.ManagedMediaSource;
|
||||
import org.schabi.newpipe.player.mediasource.ManagedMediaSourcePlaylist;
|
||||
import org.schabi.newpipe.player.mediasource.PlaceholderMediaSource;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
import org.schabi.newpipe.player.playqueue.events.MoveEvent;
|
||||
@ -195,7 +196,7 @@ public class MediaSourceManager {
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private Subscriber<PlayQueueEvent> getReactor() {
|
||||
return new Subscriber<PlayQueueEvent>() {
|
||||
return new Subscriber<>() {
|
||||
@Override
|
||||
public void onSubscribe(@NonNull final Subscription d) {
|
||||
playQueueReactor.cancel();
|
||||
@ -209,10 +210,12 @@ public class MediaSourceManager {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@NonNull final Throwable e) { }
|
||||
public void onError(@NonNull final Throwable e) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() { }
|
||||
public void onComplete() {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -292,11 +295,11 @@ public class MediaSourceManager {
|
||||
}
|
||||
|
||||
final ManagedMediaSource mediaSource = playlist.get(playQueue.getIndex());
|
||||
if (mediaSource == null) {
|
||||
final PlayQueueItem playQueueItem = playQueue.getItem();
|
||||
if (mediaSource == null || playQueueItem == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final PlayQueueItem playQueueItem = playQueue.getItem();
|
||||
return mediaSource.isStreamEqual(playQueueItem);
|
||||
}
|
||||
|
||||
@ -315,7 +318,7 @@ public class MediaSourceManager {
|
||||
isBlocked.set(true);
|
||||
}
|
||||
|
||||
private void maybeUnblock() {
|
||||
private boolean maybeUnblock() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "maybeUnblock() called.");
|
||||
}
|
||||
@ -323,14 +326,17 @@ public class MediaSourceManager {
|
||||
if (isBlocked.get()) {
|
||||
isBlocked.set(false);
|
||||
playbackListener.onPlaybackUnblock(playlist.getParentMediaSource());
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Metadata Synchronization
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private void maybeSync() {
|
||||
private void maybeSync(final boolean wasBlocked) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "maybeSync() called.");
|
||||
}
|
||||
@ -340,13 +346,13 @@ public class MediaSourceManager {
|
||||
return;
|
||||
}
|
||||
|
||||
playbackListener.onPlaybackSynchronize(currentItem);
|
||||
playbackListener.onPlaybackSynchronize(currentItem, wasBlocked);
|
||||
}
|
||||
|
||||
private synchronized void maybeSynchronizePlayer() {
|
||||
if (isPlayQueueReady() && isPlaybackReady()) {
|
||||
maybeUnblock();
|
||||
maybeSync();
|
||||
final boolean isBlockReleased = maybeUnblock();
|
||||
maybeSync(isBlockReleased);
|
||||
}
|
||||
}
|
||||
|
||||
@ -417,20 +423,29 @@ public class MediaSourceManager {
|
||||
private Single<ManagedMediaSource> getLoadedMediaSource(@NonNull final PlayQueueItem stream) {
|
||||
return stream.getStream().map(streamInfo -> {
|
||||
final MediaSource source = playbackListener.sourceOf(stream, streamInfo);
|
||||
if (source == null) {
|
||||
if (source == null || !MediaItemTag.from(source.getMediaItem()).isPresent()) {
|
||||
final String message = "Unable to resolve source from stream info. "
|
||||
+ "URL: " + stream.getUrl() + ", "
|
||||
+ "audio count: " + streamInfo.getAudioStreams().size() + ", "
|
||||
+ "video count: " + streamInfo.getVideoOnlyStreams().size() + ", "
|
||||
+ streamInfo.getVideoStreams().size();
|
||||
return new FailedMediaSource(stream, new MediaSourceResolutionException(message));
|
||||
return (ManagedMediaSource)
|
||||
FailedMediaSource.of(stream, new MediaSourceResolutionException(message));
|
||||
}
|
||||
|
||||
final MediaItemTag tag = MediaItemTag.from(source.getMediaItem()).get();
|
||||
final long expiration = System.currentTimeMillis()
|
||||
+ ServiceHelper.getCacheExpirationMillis(streamInfo.getServiceId());
|
||||
return new LoadedMediaSource(source, stream, expiration);
|
||||
}).onErrorReturn(throwable -> new FailedMediaSource(stream,
|
||||
new StreamInfoLoadException(throwable)));
|
||||
return new LoadedMediaSource(source, tag, stream, expiration);
|
||||
}).onErrorReturn(throwable -> {
|
||||
if (throwable instanceof ExtractionException) {
|
||||
return FailedMediaSource.of(stream, new StreamInfoLoadException(throwable));
|
||||
}
|
||||
// Non-source related error expected here (e.g. network),
|
||||
// should allow retry shortly after the error.
|
||||
return FailedMediaSource.of(stream, new Exception(throwable),
|
||||
/*allowRetryIn=*/TimeUnit.MILLISECONDS.convert(3, TimeUnit.SECONDS));
|
||||
});
|
||||
}
|
||||
|
||||
private void onMediaSourceReceived(@NonNull final PlayQueueItem item,
|
||||
@ -478,23 +493,23 @@ public class MediaSourceManager {
|
||||
|
||||
/**
|
||||
* Checks if the current playing index contains an expired {@link ManagedMediaSource}.
|
||||
* If so, the expired source is replaced by a {@link PlaceholderMediaSource} and
|
||||
* If so, the expired source is replaced by a dummy {@link ManagedMediaSource} and
|
||||
* {@link #loadImmediate()} is called to reload the current item.
|
||||
* <br><br>
|
||||
* If not, then the media source at the current index is ready for playback, and
|
||||
* {@link #maybeSynchronizePlayer()} is called.
|
||||
* <br><br>
|
||||
* Under both cases, {@link #maybeSync()} will be called to ensure the listener
|
||||
* Under both cases, {@link #maybeSync(boolean)} will be called to ensure the listener
|
||||
* is up-to-date.
|
||||
*/
|
||||
private void maybeRenewCurrentIndex() {
|
||||
final int currentIndex = playQueue.getIndex();
|
||||
final PlayQueueItem currentItem = playQueue.getItem();
|
||||
final ManagedMediaSource currentSource = playlist.get(currentIndex);
|
||||
if (currentSource == null) {
|
||||
if (currentItem == null || currentSource == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final PlayQueueItem currentItem = playQueue.getItem();
|
||||
if (!currentSource.shouldBeReplacedWith(currentItem, true)) {
|
||||
maybeSynchronizePlayer();
|
||||
return;
|
||||
|
@ -51,9 +51,10 @@ public interface PlaybackListener {
|
||||
* May be called anytime at any amount once unblock is called.
|
||||
* </p>
|
||||
*
|
||||
* @param item
|
||||
* @param item item the player should be playing/synchronized to
|
||||
* @param wasBlocked was the player recently released from blocking state
|
||||
*/
|
||||
void onPlaybackSynchronize(@NonNull PlayQueueItem item);
|
||||
void onPlaybackSynchronize(@NonNull PlayQueueItem item, boolean wasBlocked);
|
||||
|
||||
/**
|
||||
* Requests the listener to resolve a stream info into a media source
|
||||
|
@ -88,6 +88,8 @@ public class PlayerMediaSession implements MediaSessionCallback {
|
||||
@Override
|
||||
public void play() {
|
||||
player.play();
|
||||
// hide the player controls even if the play command came from the media session
|
||||
player.hideControls(0, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user