Merge pull request #3267 from TeamNewPipe/release_0.19.0
Release 0.19.0
This commit is contained in:
commit
dd682013f9
281 changed files with 10651 additions and 2970 deletions
3
.github/ISSUE_TEMPLATE.md
vendored
3
.github/ISSUE_TEMPLATE.md
vendored
|
@ -1,3 +0,0 @@
|
||||||
- [ ] I carefully read the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md) and agree to them.
|
|
||||||
- [ ] I checked if the issue/feature exists in the latest version.
|
|
||||||
- [ ] I did use the [incredible bugreport to markdown converter](https://teamnewpipe.github.io/CrashReportToMarkdown/) to paste bug reports.
|
|
44
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
44
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a bug report to help us improve
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Oh no, a bug! It happens. Thanks for reporting an issue with NewPipe.
|
||||||
|
|
||||||
|
Use this template to notify us if you found a bug.
|
||||||
|
|
||||||
|
To make it easier for us to help you please enter detailed information below.
|
||||||
|
|
||||||
|
Please note, we only support the latest version of NewPipe and the master branch. Make sure you have that version installed. If you don't, upgrade & reproduce the problem before opening the issue. The release page (https://github.com/TeamNewPipe/NewPipe/releases/latest) is the go-to place to get this version. In order to check your app version, open the left drawer and click on "About".
|
||||||
|
|
||||||
|
P.S.: Our contribution guidelines might be a nice document to read before you fill out the report :) You can find it at https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md
|
||||||
|
-->
|
||||||
|
### Version
|
||||||
|
<!-- Which version are you using? -->
|
||||||
|
-
|
||||||
|
|
||||||
|
|
||||||
|
### Steps to reproduce the bug
|
||||||
|
<!-- If you can't reproduce it, please try to give as many details as possible on how you think you got to the bug. -->
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Press on '....'
|
||||||
|
3. Swipe down to '....'
|
||||||
|
|
||||||
|
### Expected behavior
|
||||||
|
Tell us what you expected to happen.
|
||||||
|
|
||||||
|
### Actual behaviour
|
||||||
|
Tell us what happens instead.
|
||||||
|
|
||||||
|
### Screenshots/Screen records
|
||||||
|
If applicable, add screenshots or a screen recording to help explain your problem. GitHub should support uploading them directly in the issue field. If your file is too big, feel free to paste a link from an image/video hoster here instead.
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
If your bug includes a crash, please head over to the [incredible bugreport to markdown converter](https://teamnewpipe.github.io/CrashReportToMarkdown/). Copy the result. Paste it here:
|
||||||
|
|
||||||
|
<!-- That's right, here! -->
|
28
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
28
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
<!-- Hey. Our contribution guidelines (https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md) might be an appropriate
|
||||||
|
document to read before you fill out the request :) -->
|
||||||
|
#### Is your feature request related to a problem? Please describe it
|
||||||
|
A clear and concise description of what the problem is.
|
||||||
|
Example: *I want to do X, but there is no way to do it.*
|
||||||
|
|
||||||
|
#### Describe the solution you'd like
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
Example: *I think it would be nice if you add feature Y which makes X possible.*
|
||||||
|
|
||||||
|
#### (Optional) Describe alternatives you've considered
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
Example: *I considered Z, but that didn't turn out to be a good idea because...*
|
||||||
|
|
||||||
|
#### Additional context
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
|
Example: *Here's a photo of my cat!*
|
||||||
|
|
||||||
|
#### How will you/everyone benefit from this feature?
|
||||||
|
Convince us! How does it change your NewPipe experience and/or your life?
|
||||||
|
The better this paragraph is, the more likely a developer will think about working on it.
|
25
.github/PULL_REQUEST_TEMPLATE.md
vendored
25
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
@ -1 +1,26 @@
|
||||||
|
<!-- Hey there. Thank you so much for improving NewPipe. Please take a moment to fill out the following suggestion on how to structure this PR description. Having roughly the same layout helps everyone considerably :)-->
|
||||||
|
|
||||||
|
#### What is it?
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] Feature
|
||||||
|
|
||||||
|
#### Long description of the changes in your PR
|
||||||
|
<!-- While bullet points are the norm in this section, feel free to write a text instead if you can't fit it in a list -->
|
||||||
|
- record videos
|
||||||
|
- create clones
|
||||||
|
- take over the world
|
||||||
|
|
||||||
|
#### Fixes the following issue(s)
|
||||||
|
<!-- Also add reddit or other links which are relevant to your change. -->
|
||||||
|
-
|
||||||
|
|
||||||
|
#### Relies on the following changes
|
||||||
|
<!-- Delete this if it doesn't apply to you. -->
|
||||||
|
-
|
||||||
|
|
||||||
|
#### Testing apk
|
||||||
|
<!-- Ensure that you have your changes on a new branch which has a meaningful name. This name will be used as a suffix for the app ID to allow installing and testing multiple versions of NewPipe. Do NOT name your branches like "patch-0" and "feature-1". For example, if your PR implements a bug fix for comments, an appropriate branch name would be "commentfix". -->
|
||||||
|
debug.zip
|
||||||
|
|
||||||
|
#### Agreement
|
||||||
- [ ] I carefully read the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md) and agree to them.
|
- [ ] I carefully read the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md) and agree to them.
|
||||||
|
|
|
@ -9,13 +9,20 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "org.schabi.newpipe"
|
applicationId "org.schabi.newpipe"
|
||||||
|
resValue "string", "app_name", "NewPipe"
|
||||||
minSdkVersion 19
|
minSdkVersion 19
|
||||||
targetSdkVersion 28
|
targetSdkVersion 28
|
||||||
versionCode 870
|
versionCode 900
|
||||||
versionName "0.18.7"
|
versionName "0.19.0"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
|
||||||
|
javaCompileOptions {
|
||||||
|
annotationProcessorOptions {
|
||||||
|
arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
@ -28,7 +35,18 @@ android {
|
||||||
debug {
|
debug {
|
||||||
multiDexEnabled true
|
multiDexEnabled true
|
||||||
debuggable true
|
debuggable true
|
||||||
applicationIdSuffix ".debug"
|
|
||||||
|
// suffix the app id and the app name with git branch name
|
||||||
|
def workingBranch = getGitWorkingBranch()
|
||||||
|
def normalizedWorkingBranch = workingBranch.replaceAll("[^A-Za-z]+", "").toLowerCase()
|
||||||
|
if (normalizedWorkingBranch.isEmpty() || workingBranch == "master" || workingBranch == "dev") {
|
||||||
|
// default values when branch name could not be determined or is master or dev
|
||||||
|
applicationIdSuffix ".debug"
|
||||||
|
resValue "string", "app_name", "NewPipe Debug"
|
||||||
|
} else {
|
||||||
|
applicationIdSuffix ".debug." + normalizedWorkingBranch
|
||||||
|
resValue "string", "app_name", "NewPipe " + workingBranch
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,6 +61,15 @@ android {
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Required and used only by groupie
|
||||||
|
androidExtensions {
|
||||||
|
experimental = true
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ext {
|
ext {
|
||||||
|
@ -59,11 +86,13 @@ ext {
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||||
|
|
||||||
|
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
|
||||||
|
androidTestImplementation "android.arch.persistence.room:testing:1.1.1"
|
||||||
androidTestImplementation('androidx.test.espresso:espresso-core:3.1.0', {
|
androidTestImplementation('androidx.test.espresso:espresso-core:3.1.0', {
|
||||||
exclude module: 'support-annotations'
|
exclude module: 'support-annotations'
|
||||||
})
|
})
|
||||||
|
|
||||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:6f03c6e87'
|
implementation 'com.github.TeamNewPipe:NewPipeExtractor:69e0624e3'
|
||||||
testImplementation 'junit:junit:4.12'
|
testImplementation 'junit:junit:4.12'
|
||||||
testImplementation 'org.mockito:mockito-core:2.23.0'
|
testImplementation 'org.mockito:mockito-core:2.23.0'
|
||||||
|
|
||||||
|
@ -75,6 +104,13 @@ dependencies {
|
||||||
implementation "androidx.cardview:cardview:${androidxLibVersion}"
|
implementation "androidx.cardview:cardview:${androidxLibVersion}"
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
||||||
|
|
||||||
|
implementation 'com.xwray:groupie:2.7.0'
|
||||||
|
implementation 'com.xwray:groupie-kotlin-android-extensions:2.7.0'
|
||||||
|
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-livedata:2.0.0'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-viewmodel:2.0.0'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
|
||||||
|
|
||||||
// Originally in NewPipeExtractor
|
// Originally in NewPipeExtractor
|
||||||
implementation 'com.grack:nanojson:1.1'
|
implementation 'com.grack:nanojson:1.1'
|
||||||
implementation 'org.jsoup:jsoup:1.9.2'
|
implementation 'org.jsoup:jsoup:1.9.2'
|
||||||
|
@ -113,3 +149,19 @@ dependencies {
|
||||||
implementation "io.noties.markwon:core:${markwonVersion}"
|
implementation "io.noties.markwon:core:${markwonVersion}"
|
||||||
implementation "io.noties.markwon:linkify:${markwonVersion}"
|
implementation "io.noties.markwon:linkify:${markwonVersion}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String getGitWorkingBranch() {
|
||||||
|
try {
|
||||||
|
def gitProcess = "git rev-parse --abbrev-ref HEAD".execute()
|
||||||
|
gitProcess.waitFor()
|
||||||
|
if (gitProcess.exitValue() == 0) {
|
||||||
|
return gitProcess.text.trim()
|
||||||
|
} else {
|
||||||
|
// not a git repository
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// git was not found
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
479
app/schemas/org.schabi.newpipe.database.AppDatabase/2.json
Normal file
479
app/schemas/org.schabi.newpipe.database.AppDatabase/2.json
Normal file
|
@ -0,0 +1,479 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 2,
|
||||||
|
"identityHash": "b7856223e2595ddf20a3ce6243ce9527",
|
||||||
|
"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)",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"uid"
|
||||||
|
],
|
||||||
|
"autoGenerate": true
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_subscriptions_service_id_url",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"service_id",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX `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 `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, `title` TEXT, `stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, `thumbnail_url` TEXT)",
|
||||||
|
"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": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "streamType",
|
||||||
|
"columnName": "stream_type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "duration",
|
||||||
|
"columnName": "duration",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uploader",
|
||||||
|
"columnName": "uploader",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "thumbnailUrl",
|
||||||
|
"columnName": "thumbnail_url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"uid"
|
||||||
|
],
|
||||||
|
"autoGenerate": true
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_streams_service_id_url",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"service_id",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX `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 `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": "progressTime",
|
||||||
|
"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 `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 `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 `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 `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 `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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, \"b7856223e2595ddf20a3ce6243ce9527\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
707
app/schemas/org.schabi.newpipe.database.AppDatabase/3.json
Normal file
707
app/schemas/org.schabi.newpipe.database.AppDatabase/3.json
Normal file
|
@ -0,0 +1,707 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 3,
|
||||||
|
"identityHash": "9f825b1ee281480bedd38b971feac327",
|
||||||
|
"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)",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"uid"
|
||||||
|
],
|
||||||
|
"autoGenerate": true
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_subscriptions_service_id_url",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"service_id",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX `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 `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, `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": "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 `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 `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": "progressTime",
|
||||||
|
"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 `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 `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 `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 `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 `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 `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 `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 `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, '9f825b1ee281480bedd38b971feac327')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
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 AppDatabaseTest {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
@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("uid", null)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
|
||||||
|
testHelper.runMigrationsAndValidate(AppDatabase.DATABASE_NAME, Migrations.DB_VER_3,
|
||||||
|
true, Migrations.MIGRATION_2_3);
|
||||||
|
|
||||||
|
val migratedDatabaseV3 = getMigratedDatabase()
|
||||||
|
val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst()
|
||||||
|
assertEquals(1, listFromDB.size)
|
||||||
|
|
||||||
|
val streamFromMigratedDatabase = listFromDB.first()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMigratedDatabase(): AppDatabase {
|
||||||
|
val database: AppDatabase = Room.databaseBuilder(ApplicationProvider.getApplicationContext(),
|
||||||
|
AppDatabase::class.java, AppDatabase.DATABASE_NAME)
|
||||||
|
.build()
|
||||||
|
testHelper.closeWhenFinished(database)
|
||||||
|
return database
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,12 +6,5 @@
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".DebugApp"
|
android:name=".DebugApp"
|
||||||
android:label="NewPipe Debug"
|
tools:replace="android:name" />
|
||||||
tools:replace="android:name, android:label">
|
|
||||||
<activity
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:label="NewPipe Debug"
|
|
||||||
tools:replace="android:label"/>
|
|
||||||
</application>
|
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
|
@ -74,6 +74,7 @@
|
||||||
|
|
||||||
<service android:name=".local.subscription.services.SubscriptionsImportService"/>
|
<service android:name=".local.subscription.services.SubscriptionsImportService"/>
|
||||||
<service android:name=".local.subscription.services.SubscriptionsExportService"/>
|
<service android:name=".local.subscription.services.SubscriptionsExportService"/>
|
||||||
|
<service android:name=".local.feed.service.FeedLoadService"/>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".PanicResponderActivity"
|
android:name=".PanicResponderActivity"
|
||||||
|
@ -146,6 +147,7 @@
|
||||||
<data android:host="youtube.com"/>
|
<data android:host="youtube.com"/>
|
||||||
<data android:host="m.youtube.com"/>
|
<data android:host="m.youtube.com"/>
|
||||||
<data android:host="www.youtube.com"/>
|
<data android:host="www.youtube.com"/>
|
||||||
|
<data android:host="music.youtube.com"/>
|
||||||
<!-- video prefix -->
|
<!-- video prefix -->
|
||||||
<data android:pathPrefix="/v/"/>
|
<data android:pathPrefix="/v/"/>
|
||||||
<data android:pathPrefix="/embed/"/>
|
<data android:pathPrefix="/embed/"/>
|
||||||
|
@ -244,14 +246,7 @@
|
||||||
<data android:host="tube.poal.co"/>
|
<data android:host="tube.poal.co"/>
|
||||||
<data android:host="invidious.13ad.de"/>
|
<data android:host="invidious.13ad.de"/>
|
||||||
<data android:host="yt.elukerio.org"/>
|
<data android:host="yt.elukerio.org"/>
|
||||||
<!-- video prefix -->
|
<data android:pathPrefix="/"/>
|
||||||
<data android:pathPrefix="/embed/"/>
|
|
||||||
<data android:pathPrefix="/watch"/>
|
|
||||||
<!-- channel prefix -->
|
|
||||||
<data android:pathPrefix="/channel/"/>
|
|
||||||
<data android:pathPrefix="/user/"/>
|
|
||||||
<!-- playlist prefix -->
|
|
||||||
<data android:pathPrefix="/playlist"/>
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Soundcloud filter -->
|
<!-- Soundcloud filter -->
|
||||||
|
@ -277,8 +272,26 @@
|
||||||
<category android:name="android.intent.category.DEFAULT"/>
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
|
||||||
|
|
||||||
|
<!-- MediaCCC filter -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH"/>
|
||||||
|
<action android:name="android.nfc.action.NDEF_DISCOVERED"/>
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
|
||||||
|
<data android:scheme="http"/>
|
||||||
|
<data android:scheme="https"/>
|
||||||
|
<data android:host="media.ccc.de"/>
|
||||||
|
<!-- video prefix -->
|
||||||
|
<data android:pathPrefix="/v/"/>
|
||||||
|
<!-- channel prefix-->
|
||||||
|
<data android:pathPrefix="/c/"/>
|
||||||
|
<data android:pathPrefix="/b/"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
<service
|
<service
|
||||||
android:name=".RouterActivity$FetcherService"
|
android:name=".RouterActivity$FetcherService"
|
||||||
android:exported="false"/>
|
android:exported="false"/>
|
||||||
|
|
|
@ -0,0 +1,318 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2018 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package androidx.fragment.app;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Parcelable;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
|
||||||
|
import androidx.annotation.IntDef;
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.lifecycle.Lifecycle;
|
||||||
|
import androidx.viewpager.widget.PagerAdapter;
|
||||||
|
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
// TODO: Replace this deprecated class with its ViewPager2 counterpart
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a copy from {@link androidx.fragment.app.FragmentStatePagerAdapter}.
|
||||||
|
* <p>
|
||||||
|
* It includes a workaround to fix the menu visibility when the adapter is restored.
|
||||||
|
* <p>
|
||||||
|
* When restoring the state of this adapter, all the fragments' menu visibility were set to false,
|
||||||
|
* effectively disabling the menu from the user until he switched pages or another event that triggered the
|
||||||
|
* menu to be visible again happened.
|
||||||
|
* <p>
|
||||||
|
* <br><b>Check out the changes in:</b>
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #saveState()}</li>
|
||||||
|
* <li>{@link #restoreState(Parcelable, ClassLoader)}</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
public abstract class FragmentStatePagerAdapterMenuWorkaround extends PagerAdapter {
|
||||||
|
private static final String TAG = "FragmentStatePagerAdapt";
|
||||||
|
private static final boolean DEBUG = false;
|
||||||
|
|
||||||
|
@Retention(RetentionPolicy.SOURCE)
|
||||||
|
@IntDef({BEHAVIOR_SET_USER_VISIBLE_HINT, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT})
|
||||||
|
private @interface Behavior { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates that {@link Fragment#setUserVisibleHint(boolean)} will be called when the current
|
||||||
|
* fragment changes.
|
||||||
|
*
|
||||||
|
* @deprecated This behavior relies on the deprecated
|
||||||
|
* {@link Fragment#setUserVisibleHint(boolean)} API. Use
|
||||||
|
* {@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT} to switch to its replacement,
|
||||||
|
* {@link FragmentTransaction#setMaxLifecycle}.
|
||||||
|
* @see #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int)
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
|
public static final int BEHAVIOR_SET_USER_VISIBLE_HINT = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates that only the current fragment will be in the {@link Lifecycle.State#RESUMED}
|
||||||
|
* state. All other Fragments are capped at {@link Lifecycle.State#STARTED}.
|
||||||
|
*
|
||||||
|
* @see #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int)
|
||||||
|
*/
|
||||||
|
public static final int BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT = 1;
|
||||||
|
|
||||||
|
private final FragmentManager mFragmentManager;
|
||||||
|
private final int mBehavior;
|
||||||
|
private FragmentTransaction mCurTransaction = null;
|
||||||
|
|
||||||
|
private ArrayList<Fragment.SavedState> mSavedState = new ArrayList<Fragment.SavedState>();
|
||||||
|
private ArrayList<Fragment> mFragments = new ArrayList<Fragment>();
|
||||||
|
private Fragment mCurrentPrimaryItem = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor for {@link FragmentStatePagerAdapterMenuWorkaround} that sets the fragment manager for the
|
||||||
|
* adapter. This is the equivalent of calling
|
||||||
|
* {@link #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int)} and passing in
|
||||||
|
* {@link #BEHAVIOR_SET_USER_VISIBLE_HINT}.
|
||||||
|
*
|
||||||
|
* <p>Fragments will have {@link Fragment#setUserVisibleHint(boolean)} called whenever the
|
||||||
|
* current Fragment changes.</p>
|
||||||
|
*
|
||||||
|
* @param fm fragment manager that will interact with this adapter
|
||||||
|
* @deprecated use {@link #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int)} with
|
||||||
|
* {@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT}
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
|
public FragmentStatePagerAdapterMenuWorkaround(@NonNull FragmentManager fm) {
|
||||||
|
this(fm, BEHAVIOR_SET_USER_VISIBLE_HINT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor for {@link FragmentStatePagerAdapterMenuWorkaround}.
|
||||||
|
*
|
||||||
|
* If {@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT} is passed in, then only the current
|
||||||
|
* Fragment is in the {@link Lifecycle.State#RESUMED} state, while all other fragments are
|
||||||
|
* capped at {@link Lifecycle.State#STARTED}. If {@link #BEHAVIOR_SET_USER_VISIBLE_HINT} is
|
||||||
|
* passed, all fragments are in the {@link Lifecycle.State#RESUMED} state and there will be
|
||||||
|
* callbacks to {@link Fragment#setUserVisibleHint(boolean)}.
|
||||||
|
*
|
||||||
|
* @param fm fragment manager that will interact with this adapter
|
||||||
|
* @param behavior determines if only current fragments are in a resumed state
|
||||||
|
*/
|
||||||
|
public FragmentStatePagerAdapterMenuWorkaround(@NonNull FragmentManager fm,
|
||||||
|
@Behavior int behavior) {
|
||||||
|
mFragmentManager = fm;
|
||||||
|
mBehavior = behavior;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the Fragment associated with a specified position.
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public abstract Fragment getItem(int position);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void startUpdate(@NonNull ViewGroup container) {
|
||||||
|
if (container.getId() == View.NO_ID) {
|
||||||
|
throw new IllegalStateException("ViewPager with adapter " + this
|
||||||
|
+ " requires a view id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public Object instantiateItem(@NonNull ViewGroup container, int position) {
|
||||||
|
// If we already have this item instantiated, there is nothing
|
||||||
|
// to do. This can happen when we are restoring the entire pager
|
||||||
|
// from its saved state, where the fragment manager has already
|
||||||
|
// taken care of restoring the fragments we previously had instantiated.
|
||||||
|
if (mFragments.size() > position) {
|
||||||
|
Fragment f = mFragments.get(position);
|
||||||
|
if (f != null) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mCurTransaction == null) {
|
||||||
|
mCurTransaction = mFragmentManager.beginTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
Fragment fragment = getItem(position);
|
||||||
|
if (DEBUG) Log.v(TAG, "Adding item #" + position + ": f=" + fragment);
|
||||||
|
if (mSavedState.size() > position) {
|
||||||
|
Fragment.SavedState fss = mSavedState.get(position);
|
||||||
|
if (fss != null) {
|
||||||
|
fragment.setInitialSavedState(fss);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (mFragments.size() <= position) {
|
||||||
|
mFragments.add(null);
|
||||||
|
}
|
||||||
|
fragment.setMenuVisibility(false);
|
||||||
|
if (mBehavior == BEHAVIOR_SET_USER_VISIBLE_HINT) {
|
||||||
|
fragment.setUserVisibleHint(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
mFragments.set(position, fragment);
|
||||||
|
mCurTransaction.add(container.getId(), fragment);
|
||||||
|
|
||||||
|
if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
|
||||||
|
mCurTransaction.setMaxLifecycle(fragment, Lifecycle.State.STARTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
|
||||||
|
Fragment fragment = (Fragment) object;
|
||||||
|
|
||||||
|
if (mCurTransaction == null) {
|
||||||
|
mCurTransaction = mFragmentManager.beginTransaction();
|
||||||
|
}
|
||||||
|
if (DEBUG) Log.v(TAG, "Removing item #" + position + ": f=" + object
|
||||||
|
+ " v=" + ((Fragment)object).getView());
|
||||||
|
while (mSavedState.size() <= position) {
|
||||||
|
mSavedState.add(null);
|
||||||
|
}
|
||||||
|
mSavedState.set(position, fragment.isAdded()
|
||||||
|
? mFragmentManager.saveFragmentInstanceState(fragment) : null);
|
||||||
|
mFragments.set(position, null);
|
||||||
|
|
||||||
|
mCurTransaction.remove(fragment);
|
||||||
|
if (fragment == mCurrentPrimaryItem) {
|
||||||
|
mCurrentPrimaryItem = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings({"ReferenceEquality", "deprecation"})
|
||||||
|
public void setPrimaryItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
|
||||||
|
Fragment fragment = (Fragment)object;
|
||||||
|
if (fragment != mCurrentPrimaryItem) {
|
||||||
|
if (mCurrentPrimaryItem != null) {
|
||||||
|
mCurrentPrimaryItem.setMenuVisibility(false);
|
||||||
|
if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
|
||||||
|
if (mCurTransaction == null) {
|
||||||
|
mCurTransaction = mFragmentManager.beginTransaction();
|
||||||
|
}
|
||||||
|
mCurTransaction.setMaxLifecycle(mCurrentPrimaryItem, Lifecycle.State.STARTED);
|
||||||
|
} else {
|
||||||
|
mCurrentPrimaryItem.setUserVisibleHint(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fragment.setMenuVisibility(true);
|
||||||
|
if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) {
|
||||||
|
if (mCurTransaction == null) {
|
||||||
|
mCurTransaction = mFragmentManager.beginTransaction();
|
||||||
|
}
|
||||||
|
mCurTransaction.setMaxLifecycle(fragment, Lifecycle.State.RESUMED);
|
||||||
|
} else {
|
||||||
|
fragment.setUserVisibleHint(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
mCurrentPrimaryItem = fragment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void finishUpdate(@NonNull ViewGroup container) {
|
||||||
|
if (mCurTransaction != null) {
|
||||||
|
mCurTransaction.commitNowAllowingStateLoss();
|
||||||
|
mCurTransaction = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isViewFromObject(@NonNull View view, @NonNull Object object) {
|
||||||
|
return ((Fragment)object).getView() == view;
|
||||||
|
}
|
||||||
|
|
||||||
|
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||||
|
private final String SELECTED_FRAGMENT = "selected_fragment";
|
||||||
|
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Nullable
|
||||||
|
public Parcelable saveState() {
|
||||||
|
Bundle state = null;
|
||||||
|
if (mSavedState.size() > 0) {
|
||||||
|
state = new Bundle();
|
||||||
|
Fragment.SavedState[] fss = new Fragment.SavedState[mSavedState.size()];
|
||||||
|
mSavedState.toArray(fss);
|
||||||
|
state.putParcelableArray("states", fss);
|
||||||
|
}
|
||||||
|
for (int i=0; i<mFragments.size(); i++) {
|
||||||
|
Fragment f = mFragments.get(i);
|
||||||
|
if (f != null && f.isAdded()) {
|
||||||
|
if (state == null) {
|
||||||
|
state = new Bundle();
|
||||||
|
}
|
||||||
|
String key = "f" + i;
|
||||||
|
mFragmentManager.putFragment(state, key, f);
|
||||||
|
|
||||||
|
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||||
|
// Check if it's the same fragment instance
|
||||||
|
if (f == mCurrentPrimaryItem) {
|
||||||
|
state.putString(SELECTED_FRAGMENT, key);
|
||||||
|
}
|
||||||
|
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void restoreState(@Nullable Parcelable state, @Nullable ClassLoader loader) {
|
||||||
|
if (state != null) {
|
||||||
|
Bundle bundle = (Bundle)state;
|
||||||
|
bundle.setClassLoader(loader);
|
||||||
|
Parcelable[] fss = bundle.getParcelableArray("states");
|
||||||
|
mSavedState.clear();
|
||||||
|
mFragments.clear();
|
||||||
|
if (fss != null) {
|
||||||
|
for (int i=0; i<fss.length; i++) {
|
||||||
|
mSavedState.add((Fragment.SavedState)fss[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Iterable<String> keys = bundle.keySet();
|
||||||
|
for (String key: keys) {
|
||||||
|
if (key.startsWith("f")) {
|
||||||
|
int index = Integer.parseInt(key.substring(1));
|
||||||
|
Fragment f = mFragmentManager.getFragment(bundle, key);
|
||||||
|
if (f != null) {
|
||||||
|
while (mFragments.size() <= index) {
|
||||||
|
mFragments.add(null);
|
||||||
|
}
|
||||||
|
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||||
|
final boolean wasSelected = bundle.getString(SELECTED_FRAGMENT, "").equals(key);
|
||||||
|
f.setMenuVisibility(wasSelected);
|
||||||
|
//!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||||
|
mFragments.set(index, f);
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Bad fragment at key " + key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -159,7 +159,7 @@ public class MainActivity extends AppCompatActivity {
|
||||||
.add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER, R.string.tab_subscriptions)
|
.add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER, R.string.tab_subscriptions)
|
||||||
.setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_channel));
|
.setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_channel));
|
||||||
drawerItems.getMenu()
|
drawerItems.getMenu()
|
||||||
.add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_whats_new)
|
.add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title)
|
||||||
.setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.rss));
|
.setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.rss));
|
||||||
drawerItems.getMenu()
|
drawerItems.getMenu()
|
||||||
.add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks)
|
.add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks)
|
||||||
|
@ -240,7 +240,7 @@ public class MainActivity extends AppCompatActivity {
|
||||||
NavigationHelper.openSubscriptionFragment(getSupportFragmentManager());
|
NavigationHelper.openSubscriptionFragment(getSupportFragmentManager());
|
||||||
break;
|
break;
|
||||||
case ITEM_ID_FEED:
|
case ITEM_ID_FEED:
|
||||||
NavigationHelper.openWhatsNewFragment(getSupportFragmentManager());
|
NavigationHelper.openFeedFragment(getSupportFragmentManager());
|
||||||
break;
|
break;
|
||||||
case ITEM_ID_BOOKMARKS:
|
case ITEM_ID_BOOKMARKS:
|
||||||
NavigationHelper.openBookmarksFragment(getSupportFragmentManager());
|
NavigationHelper.openBookmarksFragment(getSupportFragmentManager());
|
||||||
|
@ -389,7 +389,7 @@ public class MainActivity extends AppCompatActivity {
|
||||||
.add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER, R.string.tab_subscriptions)
|
.add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER, R.string.tab_subscriptions)
|
||||||
.setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_channel));
|
.setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_channel));
|
||||||
drawerItems.getMenu()
|
drawerItems.getMenu()
|
||||||
.add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_whats_new)
|
.add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title)
|
||||||
.setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.rss));
|
.setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.rss));
|
||||||
drawerItems.getMenu()
|
drawerItems.getMenu()
|
||||||
.add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks)
|
.add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks)
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
package org.schabi.newpipe;
|
package org.schabi.newpipe;
|
||||||
|
|
||||||
import androidx.room.Room;
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.database.Cursor;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.room.Room;
|
||||||
|
|
||||||
import org.schabi.newpipe.database.AppDatabase;
|
import org.schabi.newpipe.database.AppDatabase;
|
||||||
|
|
||||||
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
|
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
|
||||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_11_12;
|
import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
|
||||||
|
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
|
||||||
|
|
||||||
public final class NewPipeDatabase {
|
public final class NewPipeDatabase {
|
||||||
|
|
||||||
|
@ -20,8 +23,7 @@ public final class NewPipeDatabase {
|
||||||
private static AppDatabase getDatabase(Context context) {
|
private static AppDatabase getDatabase(Context context) {
|
||||||
return Room
|
return Room
|
||||||
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
|
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
|
||||||
.addMigrations(MIGRATION_11_12)
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||||
.fallbackToDestructiveMigration()
|
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -39,4 +41,14 @@ public final class NewPipeDatabase {
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void checkpoint() {
|
||||||
|
if (databaseInstance == null) {
|
||||||
|
throw new IllegalStateException("database is not initialized");
|
||||||
|
}
|
||||||
|
Cursor c = databaseInstance.query("pragma wal_checkpoint(full)", null);
|
||||||
|
if (c.moveToFirst() && c.getInt(0) == 1) {
|
||||||
|
throw new RuntimeException("Checkpoint was blocked from completing");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,12 +9,6 @@ import android.content.SharedPreferences;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.preference.PreferenceManager;
|
import android.preference.PreferenceManager;
|
||||||
import androidx.annotation.DrawableRes;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.core.app.NotificationCompat;
|
|
||||||
import androidx.appcompat.app.AlertDialog;
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.view.ContextThemeWrapper;
|
import android.view.ContextThemeWrapper;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
|
@ -26,6 +20,12 @@ import android.widget.RadioButton;
|
||||||
import android.widget.RadioGroup;
|
import android.widget.RadioGroup;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.annotation.DrawableRes;
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.appcompat.app.AlertDialog;
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.core.app.NotificationCompat;
|
||||||
import androidx.fragment.app.FragmentManager;
|
import androidx.fragment.app.FragmentManager;
|
||||||
|
|
||||||
import org.schabi.newpipe.download.DownloadDialog;
|
import org.schabi.newpipe.download.DownloadDialog;
|
||||||
|
@ -49,12 +49,11 @@ import org.schabi.newpipe.util.ListHelper;
|
||||||
import org.schabi.newpipe.util.NavigationHelper;
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
import org.schabi.newpipe.util.PermissionHelper;
|
import org.schabi.newpipe.util.PermissionHelper;
|
||||||
import org.schabi.newpipe.util.ThemeHelper;
|
import org.schabi.newpipe.util.ThemeHelper;
|
||||||
|
import org.schabi.newpipe.util.urlfinder.UrlFinder;
|
||||||
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import icepick.Icepick;
|
import icepick.Icepick;
|
||||||
|
@ -625,78 +624,18 @@ public class RouterActivity extends AppCompatActivity {
|
||||||
// Utils
|
// Utils
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
/**
|
@Nullable
|
||||||
* Removes invisible separators (\p{Z}) and punctuation characters including
|
|
||||||
* brackets (\p{P}). See http://www.regular-expressions.info/unicode.html for
|
|
||||||
* more details.
|
|
||||||
*/
|
|
||||||
private final static String REGEX_REMOVE_FROM_URL = "[\\p{Z}\\p{P}]";
|
|
||||||
|
|
||||||
private String getUrl(Intent intent) {
|
private String getUrl(Intent intent) {
|
||||||
// first gather data and find service
|
String foundUrl = null;
|
||||||
String videoUrl = null;
|
|
||||||
if (intent.getData() != null) {
|
if (intent.getData() != null) {
|
||||||
// this means the video was called though another app
|
// Called from another app
|
||||||
videoUrl = intent.getData().toString();
|
foundUrl = intent.getData().toString();
|
||||||
} else if (intent.getStringExtra(Intent.EXTRA_TEXT) != null) {
|
} else if (intent.getStringExtra(Intent.EXTRA_TEXT) != null) {
|
||||||
//this means that vidoe was called through share menu
|
// Called from the share menu
|
||||||
String extraText = intent.getStringExtra(Intent.EXTRA_TEXT);
|
final String extraText = intent.getStringExtra(Intent.EXTRA_TEXT);
|
||||||
final String[] uris = getUris(extraText);
|
foundUrl = UrlFinder.firstUrlFromInput(extraText);
|
||||||
videoUrl = uris.length > 0 ? uris[0] : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return videoUrl;
|
return foundUrl;
|
||||||
}
|
|
||||||
|
|
||||||
private String removeHeadingGibberish(final String input) {
|
|
||||||
int start = 0;
|
|
||||||
for (int i = input.indexOf("://") - 1; i >= 0; i--) {
|
|
||||||
if (!input.substring(i, i + 1).matches("\\p{L}")) {
|
|
||||||
start = i + 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return input.substring(start, input.length());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String trim(final String input) {
|
|
||||||
if (input == null || input.length() < 1) {
|
|
||||||
return input;
|
|
||||||
} else {
|
|
||||||
String output = input;
|
|
||||||
while (output.length() > 0 && output.substring(0, 1).matches(REGEX_REMOVE_FROM_URL)) {
|
|
||||||
output = output.substring(1);
|
|
||||||
}
|
|
||||||
while (output.length() > 0
|
|
||||||
&& output.substring(output.length() - 1, output.length()).matches(REGEX_REMOVE_FROM_URL)) {
|
|
||||||
output = output.substring(0, output.length() - 1);
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieves all Strings which look remotely like URLs from a text.
|
|
||||||
* Used if NewPipe was called through share menu.
|
|
||||||
*
|
|
||||||
* @param sharedText text to scan for URLs.
|
|
||||||
* @return potential URLs
|
|
||||||
*/
|
|
||||||
protected String[] getUris(final String sharedText) {
|
|
||||||
final Collection<String> result = new HashSet<>();
|
|
||||||
if (sharedText != null) {
|
|
||||||
final String[] array = sharedText.split("\\p{Space}");
|
|
||||||
for (String s : array) {
|
|
||||||
s = trim(s);
|
|
||||||
if (s.length() != 0) {
|
|
||||||
if (s.matches(".+://.+")) {
|
|
||||||
result.add(removeHeadingGibberish(s));
|
|
||||||
} else if (s.matches(".+\\..+")) {
|
|
||||||
result.add("http://" + s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.toArray(new String[result.size()]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,7 +45,8 @@ public class AboutActivity extends AppCompatActivity {
|
||||||
new SoftwareComponent("RxJava", "2016 - 2020", "RxJava Contributors", "https://github.com/ReactiveX/RxJava", StandardLicenses.APACHE2),
|
new SoftwareComponent("RxJava", "2016 - 2020", "RxJava Contributors", "https://github.com/ReactiveX/RxJava", StandardLicenses.APACHE2),
|
||||||
new SoftwareComponent("RxBinding", "2015 - 2018", "Jake Wharton", "https://github.com/JakeWharton/RxBinding", StandardLicenses.APACHE2),
|
new SoftwareComponent("RxBinding", "2015 - 2018", "Jake Wharton", "https://github.com/JakeWharton/RxBinding", StandardLicenses.APACHE2),
|
||||||
new SoftwareComponent("PrettyTime", "2012 - 2020", "Lincoln Baxter, III", "https://github.com/ocpsoft/prettytime", StandardLicenses.APACHE2),
|
new SoftwareComponent("PrettyTime", "2012 - 2020", "Lincoln Baxter, III", "https://github.com/ocpsoft/prettytime", StandardLicenses.APACHE2),
|
||||||
new SoftwareComponent("Markwon", "2017 - 2020", "Noties", "https://github.com/noties/Markwon", StandardLicenses.APACHE2)
|
new SoftwareComponent("Markwon", "2017 - 2020", "Noties", "https://github.com/noties/Markwon", StandardLicenses.APACHE2),
|
||||||
|
new SoftwareComponent("Groupie", "2016", "Lisa Wray", "https://github.com/lisawray/groupie", StandardLicenses.MIT)
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -4,6 +4,12 @@ import androidx.room.Database;
|
||||||
import androidx.room.RoomDatabase;
|
import androidx.room.RoomDatabase;
|
||||||
import androidx.room.TypeConverters;
|
import androidx.room.TypeConverters;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.database.feed.dao.FeedDAO;
|
||||||
|
import org.schabi.newpipe.database.feed.dao.FeedGroupDAO;
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedEntity;
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity;
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity;
|
||||||
import org.schabi.newpipe.database.history.dao.SearchHistoryDAO;
|
import org.schabi.newpipe.database.history.dao.SearchHistoryDAO;
|
||||||
import org.schabi.newpipe.database.history.dao.StreamHistoryDAO;
|
import org.schabi.newpipe.database.history.dao.StreamHistoryDAO;
|
||||||
import org.schabi.newpipe.database.history.model.SearchHistoryEntry;
|
import org.schabi.newpipe.database.history.model.SearchHistoryEntry;
|
||||||
|
@ -21,35 +27,33 @@ import org.schabi.newpipe.database.stream.model.StreamStateEntity;
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionDAO;
|
import org.schabi.newpipe.database.subscription.SubscriptionDAO;
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||||
|
|
||||||
import static org.schabi.newpipe.database.Migrations.DB_VER_12_0;
|
import static org.schabi.newpipe.database.Migrations.DB_VER_3;
|
||||||
|
|
||||||
@TypeConverters({Converters.class})
|
@TypeConverters({Converters.class})
|
||||||
@Database(
|
@Database(
|
||||||
entities = {
|
entities = {
|
||||||
SubscriptionEntity.class, SearchHistoryEntry.class,
|
SubscriptionEntity.class, SearchHistoryEntry.class,
|
||||||
StreamEntity.class, StreamHistoryEntity.class, StreamStateEntity.class,
|
StreamEntity.class, StreamHistoryEntity.class, StreamStateEntity.class,
|
||||||
PlaylistEntity.class, PlaylistStreamEntity.class, PlaylistRemoteEntity.class
|
PlaylistEntity.class, PlaylistStreamEntity.class, PlaylistRemoteEntity.class,
|
||||||
|
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
|
||||||
|
FeedLastUpdatedEntity.class
|
||||||
},
|
},
|
||||||
version = DB_VER_12_0,
|
version = DB_VER_3
|
||||||
exportSchema = false
|
|
||||||
)
|
)
|
||||||
public abstract class AppDatabase extends RoomDatabase {
|
public abstract class AppDatabase extends RoomDatabase {
|
||||||
|
|
||||||
public static final String DATABASE_NAME = "newpipe.db";
|
public static final String DATABASE_NAME = "newpipe.db";
|
||||||
|
|
||||||
public abstract SubscriptionDAO subscriptionDAO();
|
|
||||||
|
|
||||||
public abstract SearchHistoryDAO searchHistoryDAO();
|
public abstract SearchHistoryDAO searchHistoryDAO();
|
||||||
|
|
||||||
public abstract StreamDAO streamDAO();
|
public abstract StreamDAO streamDAO();
|
||||||
|
|
||||||
public abstract StreamHistoryDAO streamHistoryDAO();
|
public abstract StreamHistoryDAO streamHistoryDAO();
|
||||||
|
|
||||||
public abstract StreamStateDAO streamStateDAO();
|
public abstract StreamStateDAO streamStateDAO();
|
||||||
|
|
||||||
public abstract PlaylistDAO playlistDAO();
|
public abstract PlaylistDAO playlistDAO();
|
||||||
|
|
||||||
public abstract PlaylistStreamDAO playlistStreamDAO();
|
public abstract PlaylistStreamDAO playlistStreamDAO();
|
||||||
|
|
||||||
public abstract PlaylistRemoteDAO playlistRemoteDAO();
|
public abstract PlaylistRemoteDAO playlistRemoteDAO();
|
||||||
|
|
||||||
|
public abstract FeedDAO feedDAO();
|
||||||
|
public abstract FeedGroupDAO feedGroupDAO();
|
||||||
|
public abstract SubscriptionDAO subscriptionDAO();
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ package org.schabi.newpipe.database;
|
||||||
import androidx.room.TypeConverter;
|
import androidx.room.TypeConverter;
|
||||||
|
|
||||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||||
|
import org.schabi.newpipe.local.subscription.FeedGroupIcon;
|
||||||
|
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
|
||||||
|
@ -37,4 +38,18 @@ public class Converters {
|
||||||
public static String stringOf(StreamType streamType) {
|
public static String stringOf(StreamType streamType) {
|
||||||
return streamType.name();
|
return streamType.name();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
public static Integer integerOf(FeedGroupIcon feedGroupIcon) {
|
||||||
|
return feedGroupIcon.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
public static FeedGroupIcon feedGroupIconOf(Integer id) {
|
||||||
|
for (FeedGroupIcon icon : FeedGroupIcon.values()) {
|
||||||
|
if (icon.getId() == id) return icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IllegalArgumentException("There's no feed group icon with the id \"" + id + "\"");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,14 +8,14 @@ import android.util.Log;
|
||||||
import org.schabi.newpipe.BuildConfig;
|
import org.schabi.newpipe.BuildConfig;
|
||||||
|
|
||||||
public class Migrations {
|
public class Migrations {
|
||||||
|
public static final int DB_VER_1 = 1;
|
||||||
public static final int DB_VER_11_0 = 1;
|
public static final int DB_VER_2 = 2;
|
||||||
public static final int DB_VER_12_0 = 2;
|
public static final int DB_VER_3 = 3;
|
||||||
|
|
||||||
public static final boolean DEBUG = !BuildConfig.BUILD_TYPE.equals("release");
|
public static final boolean DEBUG = !BuildConfig.BUILD_TYPE.equals("release");
|
||||||
private static final String TAG = Migrations.class.getName();
|
private static final String TAG = Migrations.class.getName();
|
||||||
|
|
||||||
public static final Migration MIGRATION_11_12 = new Migration(DB_VER_11_0, DB_VER_12_0) {
|
public static final Migration MIGRATION_1_2 = new Migration(DB_VER_1, DB_VER_2) {
|
||||||
@Override
|
@Override
|
||||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||||
if(DEBUG) {
|
if(DEBUG) {
|
||||||
|
@ -71,4 +71,32 @@ public class Migrations {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public static final Migration MIGRATION_2_3 = new Migration(DB_VER_2, DB_VER_3) {
|
||||||
|
@Override
|
||||||
|
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||||
|
// Add NOT NULLs and new fields
|
||||||
|
database.execSQL("CREATE TABLE IF NOT EXISTS streams_new " +
|
||||||
|
"(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, thumbnail_url TEXT, view_count INTEGER, textual_upload_date TEXT, upload_date INTEGER," +
|
||||||
|
" is_upload_date_approximation INTEGER)");
|
||||||
|
|
||||||
|
database.execSQL("INSERT INTO streams_new (uid, service_id, url, title, stream_type, duration, uploader, thumbnail_url, view_count, textual_upload_date, upload_date, is_upload_date_approximation)"+
|
||||||
|
" SELECT uid, service_id, url, title, stream_type, duration, uploader, thumbnail_url, NULL, NULL, NULL, NULL FROM streams");
|
||||||
|
|
||||||
|
database.execSQL("DROP TABLE streams");
|
||||||
|
database.execSQL("ALTER TABLE streams_new RENAME TO streams");
|
||||||
|
database.execSQL("CREATE UNIQUE INDEX index_streams_service_id_url ON streams (service_id, url)");
|
||||||
|
|
||||||
|
// Tables for feed feature
|
||||||
|
database.execSQL("CREATE TABLE IF NOT EXISTS feed (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)");
|
||||||
|
database.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)");
|
||||||
|
database.execSQL("CREATE TABLE IF NOT EXISTS feed_group (uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)");
|
||||||
|
database.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)");
|
||||||
|
database.execSQL("CREATE TABLE IF NOT EXISTS feed_group_subscription_join (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)");
|
||||||
|
database.execSQL("CREATE INDEX index_feed_group_subscription_join_subscription_id ON feed_group_subscription_join (subscription_id)");
|
||||||
|
database.execSQL("CREATE TABLE IF NOT EXISTS feed_last_updated (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)");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,147 @@
|
||||||
|
package org.schabi.newpipe.database.feed.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedEntity
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
||||||
|
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||||
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class FeedDAO {
|
||||||
|
@Query("DELETE FROM feed")
|
||||||
|
abstract fun deleteAll(): Int
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT s.* FROM streams s
|
||||||
|
|
||||||
|
INNER JOIN feed f
|
||||||
|
ON s.uid = f.stream_id
|
||||||
|
|
||||||
|
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
|
||||||
|
|
||||||
|
LIMIT 500
|
||||||
|
""")
|
||||||
|
abstract fun getAllStreams(): Flowable<List<StreamEntity>>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT s.* FROM streams s
|
||||||
|
|
||||||
|
INNER JOIN feed f
|
||||||
|
ON s.uid = f.stream_id
|
||||||
|
|
||||||
|
INNER JOIN feed_group_subscription_join fgs
|
||||||
|
ON fgs.subscription_id = f.subscription_id
|
||||||
|
|
||||||
|
INNER JOIN feed_group fg
|
||||||
|
ON fg.uid = fgs.group_id
|
||||||
|
|
||||||
|
WHERE fgs.group_id = :groupId
|
||||||
|
|
||||||
|
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
|
||||||
|
LIMIT 500
|
||||||
|
""")
|
||||||
|
abstract fun getAllStreamsFromGroup(groupId: Long): Flowable<List<StreamEntity>>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
DELETE FROM feed WHERE
|
||||||
|
|
||||||
|
feed.stream_id IN (
|
||||||
|
SELECT s.uid FROM streams s
|
||||||
|
|
||||||
|
INNER JOIN feed f
|
||||||
|
ON s.uid = f.stream_id
|
||||||
|
|
||||||
|
WHERE s.upload_date < :date
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
abstract fun unlinkStreamsOlderThan(date: Date)
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
DELETE FROM feed
|
||||||
|
|
||||||
|
WHERE feed.subscription_id = :subscriptionId
|
||||||
|
|
||||||
|
AND feed.stream_id IN (
|
||||||
|
SELECT s.uid FROM streams s
|
||||||
|
|
||||||
|
INNER JOIN feed f
|
||||||
|
ON s.uid = f.stream_id
|
||||||
|
|
||||||
|
WHERE s.stream_type = "LIVE_STREAM" OR s.stream_type = "AUDIO_LIVE_STREAM"
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
abstract fun unlinkOldLivestreams(subscriptionId: Long)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
abstract fun insert(feedEntity: FeedEntity)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
abstract fun insertAll(entities: List<FeedEntity>): List<Long>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
internal abstract fun insertLastUpdated(lastUpdatedEntity: FeedLastUpdatedEntity): Long
|
||||||
|
|
||||||
|
@Update(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
internal abstract fun updateLastUpdated(lastUpdatedEntity: FeedLastUpdatedEntity)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open fun setLastUpdatedForSubscription(lastUpdatedEntity: FeedLastUpdatedEntity) {
|
||||||
|
val id = insertLastUpdated(lastUpdatedEntity)
|
||||||
|
|
||||||
|
if (id == -1L) {
|
||||||
|
updateLastUpdated(lastUpdatedEntity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT MIN(lu.last_updated) FROM feed_last_updated lu
|
||||||
|
|
||||||
|
INNER JOIN feed_group_subscription_join fgs
|
||||||
|
ON fgs.subscription_id = lu.subscription_id AND fgs.group_id = :groupId
|
||||||
|
""")
|
||||||
|
abstract fun oldestSubscriptionUpdate(groupId: Long): Flowable<List<Date>>
|
||||||
|
|
||||||
|
@Query("SELECT MIN(last_updated) FROM feed_last_updated")
|
||||||
|
abstract fun oldestSubscriptionUpdateFromAll(): Flowable<List<Date>>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM feed_last_updated WHERE last_updated IS NULL")
|
||||||
|
abstract fun notLoadedCount(): Flowable<Long>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT COUNT(*) FROM subscriptions s
|
||||||
|
|
||||||
|
INNER JOIN feed_group_subscription_join fgs
|
||||||
|
ON s.uid = fgs.subscription_id AND fgs.group_id = :groupId
|
||||||
|
|
||||||
|
LEFT JOIN feed_last_updated lu
|
||||||
|
ON s.uid = lu.subscription_id
|
||||||
|
|
||||||
|
WHERE lu.last_updated IS NULL
|
||||||
|
""")
|
||||||
|
abstract fun notLoadedCountForGroup(groupId: Long): Flowable<Long>
|
||||||
|
|
||||||
|
@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
|
||||||
|
""")
|
||||||
|
abstract fun getAllOutdated(outdatedThreshold: Date): Flowable<List<SubscriptionEntity>>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT s.* FROM subscriptions s
|
||||||
|
|
||||||
|
INNER JOIN feed_group_subscription_join fgs
|
||||||
|
ON s.uid = fgs.subscription_id AND fgs.group_id = :groupId
|
||||||
|
|
||||||
|
LEFT JOIN feed_last_updated lu
|
||||||
|
ON s.uid = lu.subscription_id
|
||||||
|
|
||||||
|
WHERE lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold
|
||||||
|
""")
|
||||||
|
abstract fun getAllOutdatedForGroup(groupId: Long, outdatedThreshold: Date): Flowable<List<SubscriptionEntity>>
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
package org.schabi.newpipe.database.feed.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import io.reactivex.Maybe
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class FeedGroupDAO {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM feed_group ORDER BY sort_order ASC")
|
||||||
|
abstract fun getAll(): Flowable<List<FeedGroupEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM feed_group WHERE uid = :groupId")
|
||||||
|
abstract fun getGroup(groupId: Long): Maybe<FeedGroupEntity>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open fun insert(feedGroupEntity: FeedGroupEntity): Long {
|
||||||
|
val nextSortOrder = nextSortOrder()
|
||||||
|
feedGroupEntity.sortOrder = nextSortOrder
|
||||||
|
return insertInternal(feedGroupEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Update(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
abstract fun update(feedGroupEntity: FeedGroupEntity): Int
|
||||||
|
|
||||||
|
@Query("DELETE FROM feed_group")
|
||||||
|
abstract fun deleteAll(): Int
|
||||||
|
|
||||||
|
@Query("DELETE FROM feed_group WHERE uid = :groupId")
|
||||||
|
abstract fun delete(groupId: Long): Int
|
||||||
|
|
||||||
|
@Query("SELECT subscription_id FROM feed_group_subscription_join WHERE group_id = :groupId")
|
||||||
|
abstract fun getSubscriptionIdsFor(groupId: Long): Flowable<List<Long>>
|
||||||
|
|
||||||
|
@Query("DELETE FROM feed_group_subscription_join WHERE group_id = :groupId")
|
||||||
|
abstract fun deleteSubscriptionsFromGroup(groupId: Long): Int
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
abstract fun insertSubscriptionsToGroup(entities: List<FeedGroupSubscriptionEntity>): List<Long>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List<Long>) {
|
||||||
|
deleteSubscriptionsFromGroup(groupId)
|
||||||
|
insertSubscriptionsToGroup(subscriptionIds.map { FeedGroupSubscriptionEntity(groupId, it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open fun updateOrder(orderMap: Map<Long, Long>) {
|
||||||
|
orderMap.forEach { (groupId, sortOrder) -> updateOrder(groupId, sortOrder) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("UPDATE feed_group SET sort_order = :sortOrder WHERE uid = :groupId")
|
||||||
|
abstract fun updateOrder(groupId: Long, sortOrder: Long): Int
|
||||||
|
|
||||||
|
@Query("SELECT IFNULL(MAX(sort_order) + 1, 0) FROM feed_group")
|
||||||
|
protected abstract fun nextSortOrder(): Long
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||||
|
protected abstract fun insertInternal(feedGroupEntity: FeedGroupEntity): Long
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
package org.schabi.newpipe.database.feed.model
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.Index
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.FEED_TABLE
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.STREAM_ID
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.SUBSCRIPTION_ID
|
||||||
|
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||||
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||||
|
|
||||||
|
@Entity(tableName = FEED_TABLE,
|
||||||
|
primaryKeys = [STREAM_ID, SUBSCRIPTION_ID],
|
||||||
|
indices = [Index(SUBSCRIPTION_ID)],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = StreamEntity::class,
|
||||||
|
parentColumns = [StreamEntity.STREAM_ID],
|
||||||
|
childColumns = [STREAM_ID],
|
||||||
|
onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true),
|
||||||
|
ForeignKey(
|
||||||
|
entity = SubscriptionEntity::class,
|
||||||
|
parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID],
|
||||||
|
childColumns = [SUBSCRIPTION_ID],
|
||||||
|
onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class FeedEntity(
|
||||||
|
@ColumnInfo(name = STREAM_ID)
|
||||||
|
var streamId: Long,
|
||||||
|
|
||||||
|
@ColumnInfo(name = SUBSCRIPTION_ID)
|
||||||
|
var subscriptionId: Long
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val FEED_TABLE = "feed"
|
||||||
|
|
||||||
|
const val STREAM_ID = "stream_id"
|
||||||
|
const val SUBSCRIPTION_ID = "subscription_id"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
package org.schabi.newpipe.database.feed.model
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.FEED_GROUP_TABLE
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.SORT_ORDER
|
||||||
|
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = FEED_GROUP_TABLE,
|
||||||
|
indices = [Index(SORT_ORDER)]
|
||||||
|
)
|
||||||
|
data class FeedGroupEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
@ColumnInfo(name = ID)
|
||||||
|
val uid: Long,
|
||||||
|
|
||||||
|
@ColumnInfo(name = NAME)
|
||||||
|
var name: String,
|
||||||
|
|
||||||
|
@ColumnInfo(name = ICON)
|
||||||
|
var icon: FeedGroupIcon,
|
||||||
|
|
||||||
|
@ColumnInfo(name = SORT_ORDER)
|
||||||
|
var sortOrder: Long = -1
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val FEED_GROUP_TABLE = "feed_group"
|
||||||
|
|
||||||
|
const val ID = "uid"
|
||||||
|
const val NAME = "name"
|
||||||
|
const val ICON = "icon_id"
|
||||||
|
const val SORT_ORDER = "sort_order"
|
||||||
|
|
||||||
|
const val GROUP_ALL_ID = -1L
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
package org.schabi.newpipe.database.feed.model
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.ForeignKey.CASCADE
|
||||||
|
import androidx.room.Index
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.FEED_GROUP_SUBSCRIPTION_TABLE
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.GROUP_ID
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.SUBSCRIPTION_ID
|
||||||
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = FEED_GROUP_SUBSCRIPTION_TABLE,
|
||||||
|
primaryKeys = [GROUP_ID, SUBSCRIPTION_ID],
|
||||||
|
indices = [Index(SUBSCRIPTION_ID)],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = FeedGroupEntity::class,
|
||||||
|
parentColumns = [FeedGroupEntity.ID],
|
||||||
|
childColumns = [GROUP_ID],
|
||||||
|
onDelete = CASCADE, onUpdate = CASCADE, deferred = true),
|
||||||
|
|
||||||
|
ForeignKey(
|
||||||
|
entity = SubscriptionEntity::class,
|
||||||
|
parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID],
|
||||||
|
childColumns = [SUBSCRIPTION_ID],
|
||||||
|
onDelete = CASCADE, onUpdate = CASCADE, deferred = true)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class FeedGroupSubscriptionEntity(
|
||||||
|
@ColumnInfo(name = GROUP_ID)
|
||||||
|
var feedGroupId: Long,
|
||||||
|
|
||||||
|
@ColumnInfo(name = SUBSCRIPTION_ID)
|
||||||
|
var subscriptionId: Long
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val FEED_GROUP_SUBSCRIPTION_TABLE = "feed_group_subscription_join"
|
||||||
|
|
||||||
|
const val GROUP_ID = "group_id"
|
||||||
|
const val SUBSCRIPTION_ID = "subscription_id"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
package org.schabi.newpipe.database.feed.model
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.ForeignKey
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity.Companion.FEED_LAST_UPDATED_TABLE
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity.Companion.SUBSCRIPTION_ID
|
||||||
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = FEED_LAST_UPDATED_TABLE,
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = SubscriptionEntity::class,
|
||||||
|
parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID],
|
||||||
|
childColumns = [SUBSCRIPTION_ID],
|
||||||
|
onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class FeedLastUpdatedEntity(
|
||||||
|
@PrimaryKey
|
||||||
|
@ColumnInfo(name = SUBSCRIPTION_ID)
|
||||||
|
var subscriptionId: Long,
|
||||||
|
|
||||||
|
@ColumnInfo(name = LAST_UPDATED)
|
||||||
|
var lastUpdated: Date? = null
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val FEED_LAST_UPDATED_TABLE = "feed_last_updated"
|
||||||
|
|
||||||
|
const val SUBSCRIPTION_ID = "subscription_id"
|
||||||
|
const val LAST_UPDATED = "last_updated"
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,59 +0,0 @@
|
||||||
package org.schabi.newpipe.database.history.model;
|
|
||||||
|
|
||||||
import androidx.room.ColumnInfo;
|
|
||||||
|
|
||||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
|
||||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
|
||||||
|
|
||||||
import java.util.Date;
|
|
||||||
|
|
||||||
public class StreamHistoryEntry {
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_ID)
|
|
||||||
final public long uid;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_SERVICE_ID)
|
|
||||||
final public int serviceId;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_URL)
|
|
||||||
final public String url;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_TITLE)
|
|
||||||
final public String title;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_TYPE)
|
|
||||||
final public StreamType streamType;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_DURATION)
|
|
||||||
final public long duration;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_UPLOADER)
|
|
||||||
final public String uploader;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_THUMBNAIL_URL)
|
|
||||||
final public String thumbnailUrl;
|
|
||||||
@ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
|
|
||||||
final public long streamId;
|
|
||||||
@ColumnInfo(name = StreamHistoryEntity.STREAM_ACCESS_DATE)
|
|
||||||
final public Date accessDate;
|
|
||||||
@ColumnInfo(name = StreamHistoryEntity.STREAM_REPEAT_COUNT)
|
|
||||||
final public long repeatCount;
|
|
||||||
|
|
||||||
public StreamHistoryEntry(long uid, int serviceId, String url, String title,
|
|
||||||
StreamType streamType, long duration, String uploader,
|
|
||||||
String thumbnailUrl, long streamId, Date accessDate,
|
|
||||||
long repeatCount) {
|
|
||||||
this.uid = uid;
|
|
||||||
this.serviceId = serviceId;
|
|
||||||
this.url = url;
|
|
||||||
this.title = title;
|
|
||||||
this.streamType = streamType;
|
|
||||||
this.duration = duration;
|
|
||||||
this.uploader = uploader;
|
|
||||||
this.thumbnailUrl = thumbnailUrl;
|
|
||||||
this.streamId = streamId;
|
|
||||||
this.accessDate = accessDate;
|
|
||||||
this.repeatCount = repeatCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public StreamHistoryEntity toStreamHistoryEntity() {
|
|
||||||
return new StreamHistoryEntity(streamId, accessDate, repeatCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean hasEqualValues(StreamHistoryEntry other) {
|
|
||||||
return this.uid == other.uid && streamId == other.streamId &&
|
|
||||||
accessDate.compareTo(other.accessDate) == 0;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
package org.schabi.newpipe.database.history.model
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Embedded
|
||||||
|
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
data class StreamHistoryEntry(
|
||||||
|
@Embedded
|
||||||
|
val streamEntity: StreamEntity,
|
||||||
|
|
||||||
|
@ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
|
||||||
|
val streamId: Long,
|
||||||
|
|
||||||
|
@ColumnInfo(name = StreamHistoryEntity.STREAM_ACCESS_DATE)
|
||||||
|
val accessDate: Date,
|
||||||
|
|
||||||
|
@ColumnInfo(name = StreamHistoryEntity.STREAM_REPEAT_COUNT)
|
||||||
|
val repeatCount: Long
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun toStreamHistoryEntity(): StreamHistoryEntity {
|
||||||
|
return StreamHistoryEntity(streamId, accessDate, repeatCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasEqualValues(other: StreamHistoryEntry): Boolean {
|
||||||
|
return this.streamEntity.uid == other.streamEntity.uid && streamId == other.streamId &&
|
||||||
|
accessDate.compareTo(other.accessDate) == 0
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,60 +0,0 @@
|
||||||
package org.schabi.newpipe.database.playlist;
|
|
||||||
|
|
||||||
import androidx.room.ColumnInfo;
|
|
||||||
|
|
||||||
import org.schabi.newpipe.database.LocalItem;
|
|
||||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity;
|
|
||||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
|
||||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
|
||||||
|
|
||||||
public class PlaylistStreamEntry implements LocalItem {
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_ID)
|
|
||||||
final public long uid;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_SERVICE_ID)
|
|
||||||
final public int serviceId;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_URL)
|
|
||||||
final public String url;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_TITLE)
|
|
||||||
final public String title;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_TYPE)
|
|
||||||
final public StreamType streamType;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_DURATION)
|
|
||||||
final public long duration;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_UPLOADER)
|
|
||||||
final public String uploader;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_THUMBNAIL_URL)
|
|
||||||
final public String thumbnailUrl;
|
|
||||||
@ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID)
|
|
||||||
final public long streamId;
|
|
||||||
@ColumnInfo(name = PlaylistStreamEntity.JOIN_INDEX)
|
|
||||||
final public int joinIndex;
|
|
||||||
|
|
||||||
public PlaylistStreamEntry(long uid, int serviceId, String url, String title,
|
|
||||||
StreamType streamType, long duration, String uploader,
|
|
||||||
String thumbnailUrl, long streamId, int joinIndex) {
|
|
||||||
this.uid = uid;
|
|
||||||
this.serviceId = serviceId;
|
|
||||||
this.url = url;
|
|
||||||
this.title = title;
|
|
||||||
this.streamType = streamType;
|
|
||||||
this.duration = duration;
|
|
||||||
this.uploader = uploader;
|
|
||||||
this.thumbnailUrl = thumbnailUrl;
|
|
||||||
this.streamId = streamId;
|
|
||||||
this.joinIndex = joinIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
public StreamInfoItem toStreamInfoItem() throws IllegalArgumentException {
|
|
||||||
StreamInfoItem item = new StreamInfoItem(serviceId, url, title, streamType);
|
|
||||||
item.setThumbnailUrl(thumbnailUrl);
|
|
||||||
item.setUploaderName(uploader);
|
|
||||||
item.setDuration(duration);
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public LocalItemType getLocalItemType() {
|
|
||||||
return LocalItemType.PLAYLIST_STREAM_ITEM;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
package org.schabi.newpipe.database.playlist
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Embedded
|
||||||
|
import org.schabi.newpipe.database.LocalItem
|
||||||
|
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
|
||||||
|
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
|
||||||
|
class PlaylistStreamEntry(
|
||||||
|
@Embedded
|
||||||
|
val streamEntity: StreamEntity,
|
||||||
|
|
||||||
|
@ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID)
|
||||||
|
val streamId: Long,
|
||||||
|
|
||||||
|
@ColumnInfo(name = PlaylistStreamEntity.JOIN_INDEX)
|
||||||
|
val joinIndex: Int
|
||||||
|
) : LocalItem {
|
||||||
|
|
||||||
|
@Throws(IllegalArgumentException::class)
|
||||||
|
fun toStreamInfoItem(): StreamInfoItem {
|
||||||
|
val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType)
|
||||||
|
item.duration = streamEntity.duration
|
||||||
|
item.uploaderName = streamEntity.uploader
|
||||||
|
item.thumbnailUrl = streamEntity.thumbnailUrl
|
||||||
|
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLocalItemType(): LocalItem.LocalItemType {
|
||||||
|
return LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,69 +0,0 @@
|
||||||
package org.schabi.newpipe.database.stream;
|
|
||||||
|
|
||||||
import androidx.room.ColumnInfo;
|
|
||||||
|
|
||||||
import org.schabi.newpipe.database.LocalItem;
|
|
||||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity;
|
|
||||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
|
||||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
|
||||||
|
|
||||||
import java.util.Date;
|
|
||||||
|
|
||||||
public class StreamStatisticsEntry implements LocalItem {
|
|
||||||
final public static String STREAM_LATEST_DATE = "latestAccess";
|
|
||||||
final public static String STREAM_WATCH_COUNT = "watchCount";
|
|
||||||
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_ID)
|
|
||||||
final public long uid;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_SERVICE_ID)
|
|
||||||
final public int serviceId;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_URL)
|
|
||||||
final public String url;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_TITLE)
|
|
||||||
final public String title;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_TYPE)
|
|
||||||
final public StreamType streamType;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_DURATION)
|
|
||||||
final public long duration;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_UPLOADER)
|
|
||||||
final public String uploader;
|
|
||||||
@ColumnInfo(name = StreamEntity.STREAM_THUMBNAIL_URL)
|
|
||||||
final public String thumbnailUrl;
|
|
||||||
@ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
|
|
||||||
final public long streamId;
|
|
||||||
@ColumnInfo(name = StreamStatisticsEntry.STREAM_LATEST_DATE)
|
|
||||||
final public Date latestAccessDate;
|
|
||||||
@ColumnInfo(name = StreamStatisticsEntry.STREAM_WATCH_COUNT)
|
|
||||||
final public long watchCount;
|
|
||||||
|
|
||||||
public StreamStatisticsEntry(long uid, int serviceId, String url, String title,
|
|
||||||
StreamType streamType, long duration, String uploader,
|
|
||||||
String thumbnailUrl, long streamId, Date latestAccessDate,
|
|
||||||
long watchCount) {
|
|
||||||
this.uid = uid;
|
|
||||||
this.serviceId = serviceId;
|
|
||||||
this.url = url;
|
|
||||||
this.title = title;
|
|
||||||
this.streamType = streamType;
|
|
||||||
this.duration = duration;
|
|
||||||
this.uploader = uploader;
|
|
||||||
this.thumbnailUrl = thumbnailUrl;
|
|
||||||
this.streamId = streamId;
|
|
||||||
this.latestAccessDate = latestAccessDate;
|
|
||||||
this.watchCount = watchCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
public StreamInfoItem toStreamInfoItem() {
|
|
||||||
StreamInfoItem item = new StreamInfoItem(serviceId, url, title, streamType);
|
|
||||||
item.setDuration(duration);
|
|
||||||
item.setUploaderName(uploader);
|
|
||||||
item.setThumbnailUrl(thumbnailUrl);
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public LocalItemType getLocalItemType() {
|
|
||||||
return LocalItemType.STATISTIC_STREAM_ITEM;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,42 @@
|
||||||
|
package org.schabi.newpipe.database.stream
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Embedded
|
||||||
|
import org.schabi.newpipe.database.LocalItem
|
||||||
|
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
|
||||||
|
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class StreamStatisticsEntry(
|
||||||
|
@Embedded
|
||||||
|
val streamEntity: StreamEntity,
|
||||||
|
|
||||||
|
@ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
|
||||||
|
val streamId: Long,
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_LATEST_DATE)
|
||||||
|
val latestAccessDate: Date,
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_WATCH_COUNT)
|
||||||
|
val watchCount: Long
|
||||||
|
) : LocalItem {
|
||||||
|
|
||||||
|
fun toStreamInfoItem(): StreamInfoItem {
|
||||||
|
val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType)
|
||||||
|
item.duration = streamEntity.duration
|
||||||
|
item.uploaderName = streamEntity.uploader
|
||||||
|
item.thumbnailUrl = streamEntity.thumbnailUrl
|
||||||
|
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLocalItemType(): LocalItem.LocalItemType {
|
||||||
|
return LocalItem.LocalItemType.STATISTIC_STREAM_ITEM
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val STREAM_LATEST_DATE = "latestAccess"
|
||||||
|
const val STREAM_WATCH_COUNT = "watchCount"
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,98 +0,0 @@
|
||||||
package org.schabi.newpipe.database.stream.dao;
|
|
||||||
|
|
||||||
import androidx.room.Dao;
|
|
||||||
import androidx.room.Insert;
|
|
||||||
import androidx.room.OnConflictStrategy;
|
|
||||||
import androidx.room.Query;
|
|
||||||
import androidx.room.Transaction;
|
|
||||||
|
|
||||||
import org.schabi.newpipe.database.BasicDAO;
|
|
||||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity;
|
|
||||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
|
||||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import io.reactivex.Flowable;
|
|
||||||
|
|
||||||
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE;
|
|
||||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID;
|
|
||||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_SERVICE_ID;
|
|
||||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE;
|
|
||||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_URL;
|
|
||||||
import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_HISTORY_TABLE;
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
public abstract class StreamDAO implements BasicDAO<StreamEntity> {
|
|
||||||
@Override
|
|
||||||
@Query("SELECT * FROM " + STREAM_TABLE)
|
|
||||||
public abstract Flowable<List<StreamEntity>> getAll();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Query("DELETE FROM " + STREAM_TABLE)
|
|
||||||
public abstract int deleteAll();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Query("SELECT * FROM " + STREAM_TABLE + " WHERE " + STREAM_SERVICE_ID + " = :serviceId")
|
|
||||||
public abstract Flowable<List<StreamEntity>> listByService(int serviceId);
|
|
||||||
|
|
||||||
@Query("SELECT * FROM " + STREAM_TABLE + " WHERE " +
|
|
||||||
STREAM_URL + " = :url AND " +
|
|
||||||
STREAM_SERVICE_ID + " = :serviceId")
|
|
||||||
public abstract Flowable<List<StreamEntity>> getStream(long serviceId, String url);
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
|
||||||
abstract void silentInsertAllInternal(final List<StreamEntity> streams);
|
|
||||||
|
|
||||||
@Query("SELECT " + STREAM_ID + " FROM " + STREAM_TABLE + " WHERE " +
|
|
||||||
STREAM_URL + " = :url AND " +
|
|
||||||
STREAM_SERVICE_ID + " = :serviceId")
|
|
||||||
abstract Long getStreamIdInternal(long serviceId, String url);
|
|
||||||
|
|
||||||
@Transaction
|
|
||||||
public long upsert(StreamEntity stream) {
|
|
||||||
final Long streamIdCandidate = getStreamIdInternal(stream.getServiceId(), stream.getUrl());
|
|
||||||
|
|
||||||
if (streamIdCandidate == null) {
|
|
||||||
return insert(stream);
|
|
||||||
} else {
|
|
||||||
stream.setUid(streamIdCandidate);
|
|
||||||
update(stream);
|
|
||||||
return streamIdCandidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transaction
|
|
||||||
public List<Long> upsertAll(List<StreamEntity> streams) {
|
|
||||||
silentInsertAllInternal(streams);
|
|
||||||
|
|
||||||
final List<Long> streamIds = new ArrayList<>(streams.size());
|
|
||||||
for (StreamEntity stream : streams) {
|
|
||||||
final Long streamId = getStreamIdInternal(stream.getServiceId(), stream.getUrl());
|
|
||||||
if (streamId == null) {
|
|
||||||
throw new IllegalStateException("StreamID cannot be null just after insertion.");
|
|
||||||
}
|
|
||||||
|
|
||||||
streamIds.add(streamId);
|
|
||||||
stream.setUid(streamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
update(streams);
|
|
||||||
return streamIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Query("DELETE FROM " + STREAM_TABLE + " WHERE " + STREAM_ID +
|
|
||||||
" NOT IN " +
|
|
||||||
"(SELECT DISTINCT " + STREAM_ID + " FROM " + STREAM_TABLE +
|
|
||||||
|
|
||||||
" LEFT JOIN " + STREAM_HISTORY_TABLE +
|
|
||||||
" ON " + STREAM_ID + " = " +
|
|
||||||
StreamHistoryEntity.STREAM_HISTORY_TABLE + "." + StreamHistoryEntity.JOIN_STREAM_ID +
|
|
||||||
|
|
||||||
" LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE +
|
|
||||||
" ON " + STREAM_ID + " = " +
|
|
||||||
PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE + "." + PlaylistStreamEntity.JOIN_STREAM_ID +
|
|
||||||
")")
|
|
||||||
public abstract int deleteOrphans();
|
|
||||||
}
|
|
|
@ -0,0 +1,131 @@
|
||||||
|
package org.schabi.newpipe.database.stream.dao
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import org.schabi.newpipe.database.BasicDAO
|
||||||
|
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||||
|
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamType
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class StreamDAO : BasicDAO<StreamEntity> {
|
||||||
|
@Query("SELECT * FROM streams")
|
||||||
|
abstract override fun getAll(): Flowable<List<StreamEntity>>
|
||||||
|
|
||||||
|
@Query("DELETE FROM streams")
|
||||||
|
abstract override fun deleteAll(): Int
|
||||||
|
|
||||||
|
@Query("SELECT * FROM streams WHERE service_id = :serviceId")
|
||||||
|
abstract override fun listByService(serviceId: Int): Flowable<List<StreamEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM streams WHERE url = :url AND service_id = :serviceId")
|
||||||
|
abstract fun getStream(serviceId: Long, url: String): Flowable<List<StreamEntity>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
internal abstract fun silentInsertInternal(stream: StreamEntity): Long
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
internal abstract fun silentInsertAllInternal(streams: List<StreamEntity>): List<Long>
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration
|
||||||
|
FROM streams WHERE url = :url AND service_id = :serviceId
|
||||||
|
""")
|
||||||
|
internal abstract fun getMinimalStreamForCompare(serviceId: Int, url: String): StreamCompareFeed?
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open fun upsert(newerStream: StreamEntity): Long {
|
||||||
|
val uid = silentInsertInternal(newerStream)
|
||||||
|
|
||||||
|
if (uid != -1L) {
|
||||||
|
newerStream.uid = uid
|
||||||
|
return uid
|
||||||
|
}
|
||||||
|
|
||||||
|
compareAndUpdateStream(newerStream)
|
||||||
|
|
||||||
|
update(newerStream)
|
||||||
|
return newerStream.uid
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open fun upsertAll(streams: List<StreamEntity>): List<Long> {
|
||||||
|
val insertUidList = silentInsertAllInternal(streams)
|
||||||
|
|
||||||
|
val streamIds = ArrayList<Long>(streams.size)
|
||||||
|
for ((index, uid) in insertUidList.withIndex()) {
|
||||||
|
val newerStream = streams[index]
|
||||||
|
if (uid != -1L) {
|
||||||
|
streamIds.add(uid)
|
||||||
|
newerStream.uid = uid
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
compareAndUpdateStream(newerStream)
|
||||||
|
streamIds.add(newerStream.uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
update(streams)
|
||||||
|
return streamIds
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compareAndUpdateStream(newerStream: StreamEntity) {
|
||||||
|
val existentMinimalStream = getMinimalStreamForCompare(newerStream.serviceId, newerStream.url)
|
||||||
|
?: throw IllegalStateException("Stream cannot be null just after insertion.")
|
||||||
|
newerStream.uid = existentMinimalStream.uid
|
||||||
|
|
||||||
|
val isNewerStreamLive = newerStream.streamType == AUDIO_LIVE_STREAM || newerStream.streamType == LIVE_STREAM
|
||||||
|
if (!isNewerStreamLive) {
|
||||||
|
if (existentMinimalStream.uploadDate != null && existentMinimalStream.isUploadDateApproximation != true) {
|
||||||
|
newerStream.uploadDate = existentMinimalStream.uploadDate
|
||||||
|
newerStream.textualUploadDate = existentMinimalStream.textualUploadDate
|
||||||
|
newerStream.isUploadDateApproximation = existentMinimalStream.isUploadDateApproximation
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existentMinimalStream.duration > 0 && newerStream.duration < 0) {
|
||||||
|
newerStream.duration = existentMinimalStream.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
DELETE FROM streams WHERE
|
||||||
|
|
||||||
|
NOT EXISTS (SELECT 1 FROM stream_history sh
|
||||||
|
WHERE sh.stream_id = streams.uid)
|
||||||
|
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM playlist_stream_join ps
|
||||||
|
WHERE ps.stream_id = streams.uid)
|
||||||
|
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM feed f
|
||||||
|
WHERE f.stream_id = streams.uid)
|
||||||
|
""")
|
||||||
|
abstract fun deleteOrphans(): Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal entry class used when comparing/updating an existent stream.
|
||||||
|
*/
|
||||||
|
internal data class StreamCompareFeed(
|
||||||
|
@ColumnInfo(name = STREAM_ID)
|
||||||
|
var uid: Long = 0,
|
||||||
|
|
||||||
|
@ColumnInfo(name = StreamEntity.STREAM_TYPE)
|
||||||
|
var streamType: StreamType,
|
||||||
|
|
||||||
|
@ColumnInfo(name = StreamEntity.STREAM_TEXTUAL_UPLOAD_DATE)
|
||||||
|
var textualUploadDate: String? = null,
|
||||||
|
|
||||||
|
@ColumnInfo(name = StreamEntity.STREAM_UPLOAD_DATE)
|
||||||
|
var uploadDate: Date? = null,
|
||||||
|
|
||||||
|
@ColumnInfo(name = StreamEntity.STREAM_IS_UPLOAD_DATE_APPROXIMATION)
|
||||||
|
var isUploadDateApproximation: Boolean? = null,
|
||||||
|
|
||||||
|
@ColumnInfo(name = StreamEntity.STREAM_DURATION)
|
||||||
|
var duration: Long)
|
||||||
|
}
|
|
@ -1,153 +0,0 @@
|
||||||
package org.schabi.newpipe.database.stream.model;
|
|
||||||
|
|
||||||
import androidx.room.ColumnInfo;
|
|
||||||
import androidx.room.Entity;
|
|
||||||
import androidx.room.Ignore;
|
|
||||||
import androidx.room.Index;
|
|
||||||
import androidx.room.PrimaryKey;
|
|
||||||
|
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
|
||||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
|
||||||
import org.schabi.newpipe.util.Constants;
|
|
||||||
|
|
||||||
import java.io.Serializable;
|
|
||||||
|
|
||||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_SERVICE_ID;
|
|
||||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE;
|
|
||||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_URL;
|
|
||||||
|
|
||||||
@Entity(tableName = STREAM_TABLE,
|
|
||||||
indices = {@Index(value = {STREAM_SERVICE_ID, STREAM_URL}, unique = true)})
|
|
||||||
public class StreamEntity implements Serializable {
|
|
||||||
|
|
||||||
final public static String STREAM_TABLE = "streams";
|
|
||||||
final public static String STREAM_ID = "uid";
|
|
||||||
final public static String STREAM_SERVICE_ID = "service_id";
|
|
||||||
final public static String STREAM_URL = "url";
|
|
||||||
final public static String STREAM_TITLE = "title";
|
|
||||||
final public static String STREAM_TYPE = "stream_type";
|
|
||||||
final public static String STREAM_DURATION = "duration";
|
|
||||||
final public static String STREAM_UPLOADER = "uploader";
|
|
||||||
final public static String STREAM_THUMBNAIL_URL = "thumbnail_url";
|
|
||||||
|
|
||||||
@PrimaryKey(autoGenerate = true)
|
|
||||||
@ColumnInfo(name = STREAM_ID)
|
|
||||||
private long uid = 0;
|
|
||||||
|
|
||||||
@ColumnInfo(name = STREAM_SERVICE_ID)
|
|
||||||
private int serviceId = Constants.NO_SERVICE_ID;
|
|
||||||
|
|
||||||
@ColumnInfo(name = STREAM_URL)
|
|
||||||
private String url;
|
|
||||||
|
|
||||||
@ColumnInfo(name = STREAM_TITLE)
|
|
||||||
private String title;
|
|
||||||
|
|
||||||
@ColumnInfo(name = STREAM_TYPE)
|
|
||||||
private StreamType streamType;
|
|
||||||
|
|
||||||
@ColumnInfo(name = STREAM_DURATION)
|
|
||||||
private Long duration;
|
|
||||||
|
|
||||||
@ColumnInfo(name = STREAM_UPLOADER)
|
|
||||||
private String uploader;
|
|
||||||
|
|
||||||
@ColumnInfo(name = STREAM_THUMBNAIL_URL)
|
|
||||||
private String thumbnailUrl;
|
|
||||||
|
|
||||||
public StreamEntity(final int serviceId, final String title, final String url,
|
|
||||||
final StreamType streamType, final String thumbnailUrl, final String uploader,
|
|
||||||
final long duration) {
|
|
||||||
this.serviceId = serviceId;
|
|
||||||
this.title = title;
|
|
||||||
this.url = url;
|
|
||||||
this.streamType = streamType;
|
|
||||||
this.thumbnailUrl = thumbnailUrl;
|
|
||||||
this.uploader = uploader;
|
|
||||||
this.duration = duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Ignore
|
|
||||||
public StreamEntity(final StreamInfoItem item) {
|
|
||||||
this(item.getServiceId(), item.getName(), item.getUrl(), item.getStreamType(), item.getThumbnailUrl(),
|
|
||||||
item.getUploaderName(), item.getDuration());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Ignore
|
|
||||||
public StreamEntity(final StreamInfo info) {
|
|
||||||
this(info.getServiceId(), info.getName(), info.getUrl(), info.getStreamType(), info.getThumbnailUrl(),
|
|
||||||
info.getUploaderName(), info.getDuration());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Ignore
|
|
||||||
public StreamEntity(final PlayQueueItem item) {
|
|
||||||
this(item.getServiceId(), item.getTitle(), item.getUrl(), item.getStreamType(),
|
|
||||||
item.getThumbnailUrl(), item.getUploader(), item.getDuration());
|
|
||||||
}
|
|
||||||
|
|
||||||
public long getUid() {
|
|
||||||
return uid;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setUid(long uid) {
|
|
||||||
this.uid = uid;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getServiceId() {
|
|
||||||
return serviceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setServiceId(int serviceId) {
|
|
||||||
this.serviceId = serviceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getUrl() {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setUrl(String url) {
|
|
||||||
this.url = url;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getTitle() {
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setTitle(String title) {
|
|
||||||
this.title = title;
|
|
||||||
}
|
|
||||||
|
|
||||||
public StreamType getStreamType() {
|
|
||||||
return streamType;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setStreamType(StreamType type) {
|
|
||||||
this.streamType = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Long getDuration() {
|
|
||||||
return duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setDuration(Long duration) {
|
|
||||||
this.duration = duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getUploader() {
|
|
||||||
return uploader;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setUploader(String uploader) {
|
|
||||||
this.uploader = uploader;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getThumbnailUrl() {
|
|
||||||
return thumbnailUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setThumbnailUrl(String thumbnailUrl) {
|
|
||||||
this.thumbnailUrl = thumbnailUrl;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,115 @@
|
||||||
|
package org.schabi.newpipe.database.stream.model
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_SERVICE_ID
|
||||||
|
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_TABLE
|
||||||
|
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_URL
|
||||||
|
import org.schabi.newpipe.extractor.localization.DateWrapper
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfo
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamType
|
||||||
|
import org.schabi.newpipe.player.playqueue.PlayQueueItem
|
||||||
|
import java.io.Serializable
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@Entity(tableName = STREAM_TABLE,
|
||||||
|
indices = [
|
||||||
|
Index(value = [STREAM_SERVICE_ID, STREAM_URL], unique = true)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
data class StreamEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
@ColumnInfo(name = STREAM_ID)
|
||||||
|
var uid: Long = 0,
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_SERVICE_ID)
|
||||||
|
var serviceId: Int,
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_URL)
|
||||||
|
var url: String,
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_TITLE)
|
||||||
|
var title: String,
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_TYPE)
|
||||||
|
var streamType: StreamType,
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_DURATION)
|
||||||
|
var duration: Long,
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_UPLOADER)
|
||||||
|
var uploader: String,
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_THUMBNAIL_URL)
|
||||||
|
var thumbnailUrl: String? = null,
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_VIEWS)
|
||||||
|
var viewCount: Long? = null,
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_TEXTUAL_UPLOAD_DATE)
|
||||||
|
var textualUploadDate: String? = null,
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_UPLOAD_DATE)
|
||||||
|
var uploadDate: Date? = null,
|
||||||
|
|
||||||
|
@ColumnInfo(name = STREAM_IS_UPLOAD_DATE_APPROXIMATION)
|
||||||
|
var isUploadDateApproximation: Boolean? = null
|
||||||
|
) : Serializable {
|
||||||
|
|
||||||
|
@Ignore
|
||||||
|
constructor(item: StreamInfoItem) : this(
|
||||||
|
serviceId = item.serviceId, url = item.url, title = item.name,
|
||||||
|
streamType = item.streamType, duration = item.duration, uploader = item.uploaderName,
|
||||||
|
thumbnailUrl = item.thumbnailUrl, viewCount = item.viewCount,
|
||||||
|
textualUploadDate = item.textualUploadDate, uploadDate = item.uploadDate?.date()?.time,
|
||||||
|
isUploadDateApproximation = item.uploadDate?.isApproximation
|
||||||
|
)
|
||||||
|
|
||||||
|
@Ignore
|
||||||
|
constructor(info: StreamInfo) : this(
|
||||||
|
serviceId = info.serviceId, url = info.url, title = info.name,
|
||||||
|
streamType = info.streamType, duration = info.duration, uploader = info.uploaderName,
|
||||||
|
thumbnailUrl = info.thumbnailUrl, viewCount = info.viewCount,
|
||||||
|
textualUploadDate = info.textualUploadDate, uploadDate = info.uploadDate?.date()?.time,
|
||||||
|
isUploadDateApproximation = info.uploadDate?.isApproximation
|
||||||
|
)
|
||||||
|
|
||||||
|
@Ignore
|
||||||
|
constructor(item: PlayQueueItem) : this(
|
||||||
|
serviceId = item.serviceId, url = item.url, title = item.title,
|
||||||
|
streamType = item.streamType, duration = item.duration, uploader = item.uploader,
|
||||||
|
thumbnailUrl = item.thumbnailUrl
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toStreamInfoItem(): StreamInfoItem {
|
||||||
|
val item = StreamInfoItem(serviceId, url, title, streamType)
|
||||||
|
item.duration = duration
|
||||||
|
item.uploaderName = uploader
|
||||||
|
item.thumbnailUrl = thumbnailUrl
|
||||||
|
|
||||||
|
if (viewCount != null) item.viewCount = viewCount as Long
|
||||||
|
item.textualUploadDate = textualUploadDate
|
||||||
|
item.uploadDate = uploadDate?.let {
|
||||||
|
DateWrapper(Calendar.getInstance().apply { time = it }, isUploadDateApproximation ?: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val STREAM_TABLE = "streams"
|
||||||
|
const val STREAM_ID = "uid"
|
||||||
|
const val STREAM_SERVICE_ID = "service_id"
|
||||||
|
const val STREAM_URL = "url"
|
||||||
|
const val STREAM_TITLE = "title"
|
||||||
|
const val STREAM_TYPE = "stream_type"
|
||||||
|
const val STREAM_DURATION = "duration"
|
||||||
|
const val STREAM_UPLOADER = "uploader"
|
||||||
|
const val STREAM_THUMBNAIL_URL = "thumbnail_url"
|
||||||
|
|
||||||
|
const val STREAM_VIEWS = "view_count"
|
||||||
|
const val STREAM_TEXTUAL_UPLOAD_DATE = "textual_upload_date"
|
||||||
|
const val STREAM_UPLOAD_DATE = "upload_date"
|
||||||
|
const val STREAM_IS_UPLOAD_DATE_APPROXIMATION = "is_upload_date_approximation"
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,69 +0,0 @@
|
||||||
package org.schabi.newpipe.database.subscription;
|
|
||||||
|
|
||||||
import androidx.room.Dao;
|
|
||||||
import androidx.room.Insert;
|
|
||||||
import androidx.room.OnConflictStrategy;
|
|
||||||
import androidx.room.Query;
|
|
||||||
import androidx.room.Transaction;
|
|
||||||
|
|
||||||
import org.schabi.newpipe.database.BasicDAO;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import io.reactivex.Flowable;
|
|
||||||
|
|
||||||
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID;
|
|
||||||
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE;
|
|
||||||
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_UID;
|
|
||||||
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_URL;
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
public abstract class SubscriptionDAO implements BasicDAO<SubscriptionEntity> {
|
|
||||||
@Override
|
|
||||||
@Query("SELECT * FROM " + SUBSCRIPTION_TABLE)
|
|
||||||
public abstract Flowable<List<SubscriptionEntity>> getAll();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Query("DELETE FROM " + SUBSCRIPTION_TABLE)
|
|
||||||
public abstract int deleteAll();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " + SUBSCRIPTION_SERVICE_ID + " = :serviceId")
|
|
||||||
public abstract Flowable<List<SubscriptionEntity>> listByService(int serviceId);
|
|
||||||
|
|
||||||
@Query("SELECT * FROM " + SUBSCRIPTION_TABLE + " WHERE " +
|
|
||||||
SUBSCRIPTION_URL + " LIKE :url AND " +
|
|
||||||
SUBSCRIPTION_SERVICE_ID + " = :serviceId")
|
|
||||||
public abstract Flowable<List<SubscriptionEntity>> getSubscription(int serviceId, String url);
|
|
||||||
|
|
||||||
@Query("SELECT " + SUBSCRIPTION_UID + " FROM " + SUBSCRIPTION_TABLE + " WHERE " +
|
|
||||||
SUBSCRIPTION_URL + " LIKE :url AND " +
|
|
||||||
SUBSCRIPTION_SERVICE_ID + " = :serviceId")
|
|
||||||
abstract Long getSubscriptionIdInternal(int serviceId, String url);
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
|
||||||
abstract Long insertInternal(final SubscriptionEntity entities);
|
|
||||||
|
|
||||||
@Transaction
|
|
||||||
public List<SubscriptionEntity> upsertAll(List<SubscriptionEntity> entities) {
|
|
||||||
for (SubscriptionEntity entity : entities) {
|
|
||||||
Long uid = insertInternal(entity);
|
|
||||||
|
|
||||||
if (uid != -1) {
|
|
||||||
entity.setUid(uid);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
uid = getSubscriptionIdInternal(entity.getServiceId(), entity.getUrl());
|
|
||||||
entity.setUid(uid);
|
|
||||||
|
|
||||||
if (uid == -1) {
|
|
||||||
throw new IllegalStateException("Invalid subscription id (-1)");
|
|
||||||
}
|
|
||||||
|
|
||||||
update(entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
return entities;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
package org.schabi.newpipe.database.subscription
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import io.reactivex.Maybe
|
||||||
|
import org.schabi.newpipe.database.BasicDAO
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
||||||
|
@Query("SELECT COUNT(*) FROM subscriptions")
|
||||||
|
abstract fun rowCount(): Flowable<Long>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM subscriptions WHERE service_id = :serviceId")
|
||||||
|
abstract override fun listByService(serviceId: Int): Flowable<List<SubscriptionEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM subscriptions ORDER BY name COLLATE NOCASE ASC")
|
||||||
|
abstract override fun getAll(): Flowable<List<SubscriptionEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId")
|
||||||
|
abstract fun getSubscriptionFlowable(serviceId: Int, url: String): Flowable<List<SubscriptionEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId")
|
||||||
|
abstract fun getSubscription(serviceId: Int, url: String): Maybe<SubscriptionEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM subscriptions WHERE uid = :subscriptionId")
|
||||||
|
abstract fun getSubscription(subscriptionId: Long): SubscriptionEntity
|
||||||
|
|
||||||
|
@Query("DELETE FROM subscriptions")
|
||||||
|
abstract override fun deleteAll(): Int
|
||||||
|
|
||||||
|
@Query("DELETE FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId")
|
||||||
|
abstract fun deleteSubscription(serviceId: Int, url: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT uid FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId")
|
||||||
|
internal abstract fun getSubscriptionIdInternal(serviceId: Int, url: String): Long?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
internal abstract fun silentInsertAllInternal(entities: List<SubscriptionEntity>): List<Long>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
open fun upsertAll(entities: List<SubscriptionEntity>): List<SubscriptionEntity> {
|
||||||
|
val insertUidList = silentInsertAllInternal(entities)
|
||||||
|
|
||||||
|
insertUidList.forEachIndexed { index: Int, uidFromInsert: Long ->
|
||||||
|
val entity = entities[index]
|
||||||
|
|
||||||
|
if (uidFromInsert != -1L) {
|
||||||
|
entity.uid = uidFromInsert
|
||||||
|
} else {
|
||||||
|
val subscriptionIdFromDb = getSubscriptionIdInternal(entity.serviceId, entity.url)
|
||||||
|
?: throw IllegalStateException("Subscription cannot be null just after insertion.")
|
||||||
|
entity.uid = subscriptionIdFromDb
|
||||||
|
|
||||||
|
update(entity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,14 +19,14 @@ import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCR
|
||||||
indices = {@Index(value = {SUBSCRIPTION_SERVICE_ID, SUBSCRIPTION_URL}, unique = true)})
|
indices = {@Index(value = {SUBSCRIPTION_SERVICE_ID, SUBSCRIPTION_URL}, unique = true)})
|
||||||
public class SubscriptionEntity {
|
public class SubscriptionEntity {
|
||||||
|
|
||||||
final static String SUBSCRIPTION_UID = "uid";
|
public static final String SUBSCRIPTION_UID = "uid";
|
||||||
final static String SUBSCRIPTION_TABLE = "subscriptions";
|
public static final String SUBSCRIPTION_TABLE = "subscriptions";
|
||||||
final static String SUBSCRIPTION_SERVICE_ID = "service_id";
|
public static final String SUBSCRIPTION_SERVICE_ID = "service_id";
|
||||||
final static String SUBSCRIPTION_URL = "url";
|
public static final String SUBSCRIPTION_URL = "url";
|
||||||
final static String SUBSCRIPTION_NAME = "name";
|
public static final String SUBSCRIPTION_NAME = "name";
|
||||||
final static String SUBSCRIPTION_AVATAR_URL = "avatar_url";
|
public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url";
|
||||||
final static String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
|
public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
|
||||||
final static String SUBSCRIPTION_DESCRIPTION = "description";
|
public static final String SUBSCRIPTION_DESCRIPTION = "description";
|
||||||
|
|
||||||
@PrimaryKey(autoGenerate = true)
|
@PrimaryKey(autoGenerate = true)
|
||||||
private long uid = 0;
|
private long uid = 0;
|
||||||
|
|
|
@ -17,6 +17,7 @@ import org.schabi.newpipe.BaseFragment;
|
||||||
import org.schabi.newpipe.MainActivity;
|
import org.schabi.newpipe.MainActivity;
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.ReCaptchaActivity;
|
import org.schabi.newpipe.ReCaptchaActivity;
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
|
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
|
||||||
import org.schabi.newpipe.report.ErrorActivity;
|
import org.schabi.newpipe.report.ErrorActivity;
|
||||||
import org.schabi.newpipe.report.UserAction;
|
import org.schabi.newpipe.report.UserAction;
|
||||||
|
@ -181,6 +182,9 @@ public abstract class BaseStateFragment<I> extends BaseFragment implements ViewC
|
||||||
if (exception instanceof ReCaptchaException) {
|
if (exception instanceof ReCaptchaException) {
|
||||||
onReCaptchaException((ReCaptchaException) exception);
|
onReCaptchaException((ReCaptchaException) exception);
|
||||||
return true;
|
return true;
|
||||||
|
} else if (exception instanceof ContentNotAvailableException) {
|
||||||
|
showError(getString(R.string.content_not_available), false);
|
||||||
|
return true;
|
||||||
} else if (exception instanceof IOException) {
|
} else if (exception instanceof IOException) {
|
||||||
showError(getString(R.string.network_error), true);
|
showError(getString(R.string.network_error), true);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
@ -16,7 +16,7 @@ import androidx.appcompat.app.ActionBar;
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
import androidx.fragment.app.Fragment;
|
import androidx.fragment.app.Fragment;
|
||||||
import androidx.fragment.app.FragmentManager;
|
import androidx.fragment.app.FragmentManager;
|
||||||
import androidx.fragment.app.FragmentStatePagerAdapter;
|
import androidx.fragment.app.FragmentStatePagerAdapterMenuWorkaround;
|
||||||
import androidx.viewpager.widget.ViewPager;
|
import androidx.viewpager.widget.ViewPager;
|
||||||
|
|
||||||
import com.google.android.material.tabs.TabLayout;
|
import com.google.android.material.tabs.TabLayout;
|
||||||
|
@ -136,16 +136,17 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
||||||
// Tabs
|
// Tabs
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
public void setupTabs() {
|
private void setupTabs() {
|
||||||
tabsList.clear();
|
tabsList.clear();
|
||||||
tabsList.addAll(tabsManager.getTabs());
|
tabsList.addAll(tabsManager.getTabs());
|
||||||
|
|
||||||
if (pagerAdapter == null || !pagerAdapter.sameTabs(tabsList)) {
|
if (pagerAdapter == null || !pagerAdapter.sameTabs(tabsList)) {
|
||||||
pagerAdapter = new SelectedTabsPagerAdapter(requireContext(), getChildFragmentManager(), tabsList);
|
pagerAdapter = new SelectedTabsPagerAdapter(requireContext(), getChildFragmentManager(), tabsList);
|
||||||
}
|
}
|
||||||
// Clear previous tabs/fragments and set new adapter
|
|
||||||
viewPager.setAdapter(pagerAdapter);
|
viewPager.setAdapter(null);
|
||||||
viewPager.setOffscreenPageLimit(tabsList.size());
|
viewPager.setOffscreenPageLimit(tabsList.size());
|
||||||
|
viewPager.setAdapter(pagerAdapter);
|
||||||
|
|
||||||
updateTabsIconAndDescription();
|
updateTabsIconAndDescription();
|
||||||
updateTitleForTab(viewPager.getCurrentItem());
|
updateTitleForTab(viewPager.getCurrentItem());
|
||||||
|
@ -184,7 +185,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
||||||
updateTitleForTab(tab.getPosition());
|
updateTitleForTab(tab.getPosition());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class SelectedTabsPagerAdapter extends FragmentStatePagerAdapter {
|
private static class SelectedTabsPagerAdapter extends FragmentStatePagerAdapterMenuWorkaround {
|
||||||
private final Context context;
|
private final Context context;
|
||||||
private final List<Tab> internalTabsList;
|
private final List<Tab> internalTabsList;
|
||||||
|
|
||||||
|
@ -194,6 +195,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
||||||
this.internalTabsList = new ArrayList<>(tabsList);
|
this.internalTabsList = new ArrayList<>(tabsList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
@Override
|
@Override
|
||||||
public Fragment getItem(int position) {
|
public Fragment getItem(int position) {
|
||||||
final Tab tab = internalTabsList.get(position);
|
final Tab tab = internalTabsList.get(position);
|
||||||
|
|
|
@ -51,7 +51,6 @@ import org.schabi.newpipe.download.DownloadDialog;
|
||||||
import org.schabi.newpipe.extractor.InfoItem;
|
import org.schabi.newpipe.extractor.InfoItem;
|
||||||
import org.schabi.newpipe.extractor.NewPipe;
|
import org.schabi.newpipe.extractor.NewPipe;
|
||||||
import org.schabi.newpipe.extractor.ServiceList;
|
import org.schabi.newpipe.extractor.ServiceList;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
||||||
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor;
|
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor;
|
||||||
|
@ -1220,20 +1219,12 @@ public class VideoDetailFragment
|
||||||
protected boolean onError(Throwable exception) {
|
protected boolean onError(Throwable exception) {
|
||||||
if (super.onError(exception)) return true;
|
if (super.onError(exception)) return true;
|
||||||
|
|
||||||
else if (exception instanceof ContentNotAvailableException) {
|
int errorId = exception instanceof YoutubeStreamExtractor.DecryptException ? R.string.youtube_signature_decryption_error
|
||||||
showError(getString(R.string.content_not_available), false);
|
: exception instanceof ExtractionException ? R.string.parsing_error
|
||||||
} else {
|
: R.string.general_error;
|
||||||
int errorId = exception instanceof YoutubeStreamExtractor.DecryptException
|
|
||||||
? R.string.youtube_signature_decryption_error
|
onUnrecoverableError(exception, UserAction.REQUESTED_STREAM,
|
||||||
: exception instanceof ParsingException
|
NewPipe.getNameOfService(serviceId), url, errorId);
|
||||||
? R.string.parsing_error
|
|
||||||
: R.string.general_error;
|
|
||||||
onUnrecoverableError(exception,
|
|
||||||
UserAction.REQUESTED_STREAM,
|
|
||||||
NewPipe.getNameOfService(serviceId),
|
|
||||||
url,
|
|
||||||
errorId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -1246,12 +1237,22 @@ public class VideoDetailFragment
|
||||||
final boolean playbackResumeEnabled =
|
final boolean playbackResumeEnabled =
|
||||||
prefs.getBoolean(activity.getString(R.string.enable_watch_history_key), true)
|
prefs.getBoolean(activity.getString(R.string.enable_watch_history_key), true)
|
||||||
&& prefs.getBoolean(activity.getString(R.string.enable_playback_resume_key), true);
|
&& prefs.getBoolean(activity.getString(R.string.enable_playback_resume_key), true);
|
||||||
|
|
||||||
if (!playbackResumeEnabled || info.getDuration() <= 0) {
|
if (!playbackResumeEnabled || info.getDuration() <= 0) {
|
||||||
positionView.setVisibility(View.INVISIBLE);
|
positionView.setVisibility(View.INVISIBLE);
|
||||||
detailPositionView.setVisibility(View.GONE);
|
detailPositionView.setVisibility(View.GONE);
|
||||||
return;
|
|
||||||
|
// TODO: Remove this check when separation of concerns is done.
|
||||||
|
// (live streams weren't getting updated because they are mixed)
|
||||||
|
if (!info.getStreamType().equals(StreamType.LIVE_STREAM) &&
|
||||||
|
!info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext());
|
final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext());
|
||||||
|
|
||||||
|
// TODO: Separate concerns when updating database data.
|
||||||
|
// (move the updating part to when the loading happens)
|
||||||
positionSubscriber = recordManager.loadStreamState(info)
|
positionSubscriber = recordManager.loadStreamState(info)
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.onErrorComplete()
|
.onErrorComplete()
|
||||||
|
|
|
@ -59,7 +59,10 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
||||||
@Override
|
@Override
|
||||||
public void onAttach(Context context) {
|
public void onAttach(Context context) {
|
||||||
super.onAttach(context);
|
super.onAttach(context);
|
||||||
infoListAdapter = new InfoListAdapter(activity);
|
|
||||||
|
if (infoListAdapter == null) {
|
||||||
|
infoListAdapter = new InfoListAdapter(activity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -78,7 +81,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
||||||
@Override
|
@Override
|
||||||
public void onDestroy() {
|
public void onDestroy() {
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
StateSaver.onDestroy(savedState);
|
if (useDefaultStateSaving) StateSaver.onDestroy(savedState);
|
||||||
PreferenceManager.getDefaultSharedPreferences(activity)
|
PreferenceManager.getDefaultSharedPreferences(activity)
|
||||||
.unregisterOnSharedPreferenceChangeListener(this);
|
.unregisterOnSharedPreferenceChangeListener(this);
|
||||||
}
|
}
|
||||||
|
@ -103,6 +106,16 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
protected StateSaver.SavedState savedState;
|
protected StateSaver.SavedState savedState;
|
||||||
|
protected boolean useDefaultStateSaving = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the default implementation of {@link StateSaver.WriteRead} should be used.
|
||||||
|
*
|
||||||
|
* @see StateSaver
|
||||||
|
*/
|
||||||
|
public void useDefaultStateSaving(boolean useDefault) {
|
||||||
|
this.useDefaultStateSaving = useDefault;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String generateSuffix() {
|
public String generateSuffix() {
|
||||||
|
@ -112,26 +125,28 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void writeTo(Queue<Object> objectsToSave) {
|
public void writeTo(Queue<Object> objectsToSave) {
|
||||||
objectsToSave.add(infoListAdapter.getItemsList());
|
if (useDefaultStateSaving) objectsToSave.add(infoListAdapter.getItemsList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public void readFrom(@NonNull Queue<Object> savedObjects) throws Exception {
|
public void readFrom(@NonNull Queue<Object> savedObjects) throws Exception {
|
||||||
infoListAdapter.getItemsList().clear();
|
if (useDefaultStateSaving) {
|
||||||
infoListAdapter.getItemsList().addAll((List<InfoItem>) savedObjects.poll());
|
infoListAdapter.getItemsList().clear();
|
||||||
|
infoListAdapter.getItemsList().addAll((List<InfoItem>) savedObjects.poll());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onSaveInstanceState(Bundle bundle) {
|
public void onSaveInstanceState(Bundle bundle) {
|
||||||
super.onSaveInstanceState(bundle);
|
super.onSaveInstanceState(bundle);
|
||||||
savedState = StateSaver.tryToSave(activity.isChangingConfigurations(), savedState, bundle, this);
|
if (useDefaultStateSaving) savedState = StateSaver.tryToSave(activity.isChangingConfigurations(), savedState, bundle, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onRestoreInstanceState(@NonNull Bundle bundle) {
|
protected void onRestoreInstanceState(@NonNull Bundle bundle) {
|
||||||
super.onRestoreInstanceState(bundle);
|
super.onRestoreInstanceState(bundle);
|
||||||
savedState = StateSaver.tryToRestore(bundle, this);
|
if (useDefaultStateSaving) savedState = StateSaver.tryToRestore(bundle, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
|
|
@ -33,7 +33,7 @@ import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||||
import org.schabi.newpipe.local.subscription.SubscriptionService;
|
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||||
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
|
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||||
import org.schabi.newpipe.report.UserAction;
|
import org.schabi.newpipe.report.UserAction;
|
||||||
|
@ -66,7 +66,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
||||||
|
|
||||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||||
private Disposable subscribeButtonMonitor;
|
private Disposable subscribeButtonMonitor;
|
||||||
private SubscriptionService subscriptionService;
|
private SubscriptionManager subscriptionManager;
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
// Views
|
// Views
|
||||||
|
@ -109,7 +109,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
||||||
@Override
|
@Override
|
||||||
public void onAttach(Context context) {
|
public void onAttach(Context context) {
|
||||||
super.onAttach(context);
|
super.onAttach(context);
|
||||||
subscriptionService = SubscriptionService.getInstance(activity);
|
subscriptionManager = new SubscriptionManager(activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -212,8 +212,8 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
||||||
0);
|
0);
|
||||||
};
|
};
|
||||||
|
|
||||||
final Observable<List<SubscriptionEntity>> observable = subscriptionService.subscriptionTable()
|
final Observable<List<SubscriptionEntity>> observable = subscriptionManager.subscriptionTable()
|
||||||
.getSubscription(info.getServiceId(), info.getUrl())
|
.getSubscriptionFlowable(info.getServiceId(), info.getUrl())
|
||||||
.toObservable();
|
.toObservable();
|
||||||
|
|
||||||
disposables.add(observable
|
disposables.add(observable
|
||||||
|
@ -231,16 +231,16 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription) {
|
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription, ChannelInfo info) {
|
||||||
return (@NonNull Object o) -> {
|
return (@NonNull Object o) -> {
|
||||||
subscriptionService.subscriptionTable().insert(subscription);
|
subscriptionManager.insertSubscription(subscription, info);
|
||||||
return o;
|
return o;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private Function<Object, Object> mapOnUnsubscribe(final SubscriptionEntity subscription) {
|
private Function<Object, Object> mapOnUnsubscribe(final SubscriptionEntity subscription) {
|
||||||
return (@NonNull Object o) -> {
|
return (@NonNull Object o) -> {
|
||||||
subscriptionService.subscriptionTable().delete(subscription);
|
subscriptionManager.deleteSubscription(subscription);
|
||||||
return o;
|
return o;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -258,7 +258,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
||||||
"Updating Subscription for " + info.getUrl(),
|
"Updating Subscription for " + info.getUrl(),
|
||||||
R.string.subscription_update_failed);
|
R.string.subscription_update_failed);
|
||||||
|
|
||||||
disposables.add(subscriptionService.updateChannelInfo(info)
|
disposables.add(subscriptionManager.updateChannelInfo(info)
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(onComplete, onError));
|
.subscribe(onComplete, onError));
|
||||||
|
@ -288,7 +288,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
||||||
private Consumer<List<SubscriptionEntity>> getSubscribeUpdateMonitor(final ChannelInfo info) {
|
private Consumer<List<SubscriptionEntity>> getSubscribeUpdateMonitor(final ChannelInfo info) {
|
||||||
return (List<SubscriptionEntity> subscriptionEntities) -> {
|
return (List<SubscriptionEntity> subscriptionEntities) -> {
|
||||||
if (DEBUG)
|
if (DEBUG)
|
||||||
Log.d(TAG, "subscriptionService.subscriptionTable.doOnNext() called with: subscriptionEntities = [" + subscriptionEntities + "]");
|
Log.d(TAG, "subscriptionManager.subscriptionTable.doOnNext() called with: subscriptionEntities = [" + subscriptionEntities + "]");
|
||||||
if (subscribeButtonMonitor != null) subscribeButtonMonitor.dispose();
|
if (subscribeButtonMonitor != null) subscribeButtonMonitor.dispose();
|
||||||
|
|
||||||
if (subscriptionEntities.isEmpty()) {
|
if (subscriptionEntities.isEmpty()) {
|
||||||
|
@ -300,7 +300,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
||||||
info.getAvatarUrl(),
|
info.getAvatarUrl(),
|
||||||
info.getDescription(),
|
info.getDescription(),
|
||||||
info.getSubscriberCount());
|
info.getSubscriberCount());
|
||||||
subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, mapOnSubscribe(channel));
|
subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, mapOnSubscribe(channel, info));
|
||||||
} else {
|
} else {
|
||||||
if (DEBUG) Log.d(TAG, "Found subscription to this channel!");
|
if (DEBUG) Log.d(TAG, "Found subscription to this channel!");
|
||||||
final SubscriptionEntity subscription = subscriptionEntities.get(0);
|
final SubscriptionEntity subscription = subscriptionEntities.get(0);
|
||||||
|
@ -440,16 +440,12 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
||||||
protected boolean onError(Throwable exception) {
|
protected boolean onError(Throwable exception) {
|
||||||
if (super.onError(exception)) return true;
|
if (super.onError(exception)) return true;
|
||||||
|
|
||||||
if (exception instanceof ContentNotAvailableException) {
|
int errorId = exception instanceof ExtractionException
|
||||||
showError(getString(R.string.content_not_available), false);
|
? R.string.parsing_error : R.string.general_error;
|
||||||
} else {
|
|
||||||
int errorId = exception instanceof ExtractionException ? R.string.parsing_error : R.string.general_error;
|
onUnrecoverableError(exception, UserAction.REQUESTED_CHANNEL,
|
||||||
onUnrecoverableError(exception,
|
NewPipe.getNameOfService(serviceId), url, errorId);
|
||||||
UserAction.REQUESTED_CHANNEL,
|
|
||||||
NewPipe.getNameOfService(serviceId),
|
|
||||||
url,
|
|
||||||
errorId);
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -122,7 +122,7 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
||||||
this.useGridVariant = useGridVariant;
|
this.useGridVariant = useGridVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void addInfoItemList(@Nullable final List<InfoItem> data) {
|
public void addInfoItemList(@Nullable final List<? extends InfoItem> data) {
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -147,6 +147,12 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setInfoItemList(List<? extends InfoItem> data) {
|
||||||
|
infoItemList.clear();
|
||||||
|
infoItemList.addAll(data);
|
||||||
|
notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
public void addInfoItem(@Nullable InfoItem data) {
|
public void addInfoItem(@Nullable InfoItem data) {
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -0,0 +1,166 @@
|
||||||
|
package org.schabi.newpipe.local.feed
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import io.reactivex.Completable
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import io.reactivex.Maybe
|
||||||
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import org.schabi.newpipe.MainActivity.DEBUG
|
||||||
|
import org.schabi.newpipe.NewPipeDatabase
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedEntity
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
|
||||||
|
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamType
|
||||||
|
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
|
class FeedDatabaseManager(context: Context) {
|
||||||
|
private val database = NewPipeDatabase.getInstance(context)
|
||||||
|
private val feedTable = database.feedDAO()
|
||||||
|
private val feedGroupTable = database.feedGroupDAO()
|
||||||
|
private val streamTable = database.streamDAO()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Only items that are newer than this will be saved.
|
||||||
|
*/
|
||||||
|
val FEED_OLDEST_ALLOWED_DATE: Calendar = Calendar.getInstance().apply {
|
||||||
|
add(Calendar.WEEK_OF_YEAR, -13)
|
||||||
|
set(Calendar.HOUR_OF_DAY, 0)
|
||||||
|
set(Calendar.MINUTE, 0)
|
||||||
|
set(Calendar.SECOND, 0)
|
||||||
|
set(Calendar.MILLISECOND, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun groups() = feedGroupTable.getAll()
|
||||||
|
|
||||||
|
fun database() = database
|
||||||
|
|
||||||
|
fun asStreamItems(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable<List<StreamInfoItem>> {
|
||||||
|
val streams = when (groupId) {
|
||||||
|
FeedGroupEntity.GROUP_ALL_ID -> feedTable.getAllStreams()
|
||||||
|
else -> feedTable.getAllStreamsFromGroup(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return streams.map<List<StreamInfoItem>> {
|
||||||
|
val items = ArrayList<StreamInfoItem>(it.size)
|
||||||
|
for (streamEntity in it) items.add(streamEntity.toStreamInfoItem())
|
||||||
|
return@map items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun outdatedSubscriptions(outdatedThreshold: Date) = feedTable.getAllOutdated(outdatedThreshold)
|
||||||
|
|
||||||
|
fun notLoadedCount(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable<Long> {
|
||||||
|
return when (groupId) {
|
||||||
|
FeedGroupEntity.GROUP_ALL_ID -> feedTable.notLoadedCount()
|
||||||
|
else -> feedTable.notLoadedCountForGroup(groupId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun outdatedSubscriptionsForGroup(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, outdatedThreshold: Date) =
|
||||||
|
feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold)
|
||||||
|
|
||||||
|
fun markAsOutdated(subscriptionId: Long) = feedTable
|
||||||
|
.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null))
|
||||||
|
|
||||||
|
fun upsertAll(subscriptionId: Long, items: List<StreamInfoItem>,
|
||||||
|
oldestAllowedDate: Date = FEED_OLDEST_ALLOWED_DATE.time) {
|
||||||
|
val itemsToInsert = ArrayList<StreamInfoItem>()
|
||||||
|
loop@ for (streamItem in items) {
|
||||||
|
val uploadDate = streamItem.uploadDate
|
||||||
|
|
||||||
|
itemsToInsert += when {
|
||||||
|
uploadDate == null && streamItem.streamType == StreamType.LIVE_STREAM -> streamItem
|
||||||
|
uploadDate != null && uploadDate.date().time >= oldestAllowedDate -> streamItem
|
||||||
|
else -> continue@loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
feedTable.unlinkOldLivestreams(subscriptionId)
|
||||||
|
|
||||||
|
if (itemsToInsert.isNotEmpty()) {
|
||||||
|
val streamEntities = itemsToInsert.map { StreamEntity(it) }
|
||||||
|
val streamIds = streamTable.upsertAll(streamEntities)
|
||||||
|
val feedEntities = streamIds.map { FeedEntity(it, subscriptionId) }
|
||||||
|
|
||||||
|
feedTable.insertAll(feedEntities)
|
||||||
|
}
|
||||||
|
|
||||||
|
feedTable.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, Calendar.getInstance().time))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeOrphansOrOlderStreams(oldestAllowedDate: Date = FEED_OLDEST_ALLOWED_DATE.time) {
|
||||||
|
feedTable.unlinkStreamsOlderThan(oldestAllowedDate)
|
||||||
|
streamTable.deleteOrphans()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
feedTable.deleteAll()
|
||||||
|
val deletedOrphans = streamTable.deleteOrphans()
|
||||||
|
if (DEBUG) Log.d(this::class.java.simpleName, "clear() → streamTable.deleteOrphans() → $deletedOrphans")
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Feed Groups
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
fun subscriptionIdsForGroup(groupId: Long): Flowable<List<Long>> {
|
||||||
|
return feedGroupTable.getSubscriptionIdsFor(groupId)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List<Long>): Completable {
|
||||||
|
return Completable.fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createGroup(name: String, icon: FeedGroupIcon): Maybe<Long> {
|
||||||
|
return Maybe.fromCallable { feedGroupTable.insert(FeedGroupEntity(0, name, icon)) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGroup(groupId: Long): Maybe<FeedGroupEntity> {
|
||||||
|
return feedGroupTable.getGroup(groupId)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateGroup(feedGroupEntity: FeedGroupEntity): Completable {
|
||||||
|
return Completable.fromCallable { feedGroupTable.update(feedGroupEntity) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteGroup(groupId: Long): Completable {
|
||||||
|
return Completable.fromCallable { feedGroupTable.delete(groupId) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateGroupsOrder(groupIdList: List<Long>): Completable {
|
||||||
|
var index = 0L
|
||||||
|
val orderMap = groupIdList.associateBy({ it }, { index++ })
|
||||||
|
|
||||||
|
return Completable.fromCallable { feedGroupTable.updateOrder(orderMap) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun oldestSubscriptionUpdate(groupId: Long): Flowable<List<Date>> {
|
||||||
|
return when (groupId) {
|
||||||
|
FeedGroupEntity.GROUP_ALL_ID -> feedTable.oldestSubscriptionUpdateFromAll()
|
||||||
|
else -> feedTable.oldestSubscriptionUpdate(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,444 +0,0 @@
|
||||||
package org.schabi.newpipe.local.feed;
|
|
||||||
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.os.Handler;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.appcompat.app.ActionBar;
|
|
||||||
import android.util.Log;
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.Menu;
|
|
||||||
import android.view.MenuInflater;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.ViewGroup;
|
|
||||||
|
|
||||||
import org.reactivestreams.Subscriber;
|
|
||||||
import org.reactivestreams.Subscription;
|
|
||||||
import org.schabi.newpipe.R;
|
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
|
||||||
import org.schabi.newpipe.extractor.InfoItem;
|
|
||||||
import org.schabi.newpipe.extractor.NewPipe;
|
|
||||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
|
||||||
import org.schabi.newpipe.fragments.list.BaseListFragment;
|
|
||||||
import org.schabi.newpipe.local.subscription.SubscriptionService;
|
|
||||||
import org.schabi.newpipe.report.UserAction;
|
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Queue;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
|
|
||||||
import io.reactivex.Flowable;
|
|
||||||
import io.reactivex.MaybeObserver;
|
|
||||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
|
||||||
import io.reactivex.disposables.CompositeDisposable;
|
|
||||||
import io.reactivex.disposables.Disposable;
|
|
||||||
|
|
||||||
public class FeedFragment extends BaseListFragment<List<SubscriptionEntity>, Void> {
|
|
||||||
|
|
||||||
private static final int OFF_SCREEN_ITEMS_COUNT = 3;
|
|
||||||
private static final int MIN_ITEMS_INITIAL_LOAD = 8;
|
|
||||||
private int FEED_LOAD_COUNT = MIN_ITEMS_INITIAL_LOAD;
|
|
||||||
|
|
||||||
private int subscriptionPoolSize;
|
|
||||||
|
|
||||||
private SubscriptionService subscriptionService;
|
|
||||||
|
|
||||||
private AtomicBoolean allItemsLoaded = new AtomicBoolean(false);
|
|
||||||
private HashSet<String> itemsLoaded = new HashSet<>();
|
|
||||||
private final AtomicInteger requestLoadedAtomic = new AtomicInteger();
|
|
||||||
|
|
||||||
private CompositeDisposable compositeDisposable = new CompositeDisposable();
|
|
||||||
private Disposable subscriptionObserver;
|
|
||||||
private Subscription feedSubscriber;
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Fragment LifeCycle
|
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreate(Bundle savedInstanceState) {
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
subscriptionService = SubscriptionService.getInstance(activity);
|
|
||||||
|
|
||||||
FEED_LOAD_COUNT = howManyItemsToLoad();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
|
|
||||||
|
|
||||||
if(!useAsFrontPage) {
|
|
||||||
setTitle(activity.getString(R.string.fragment_whats_new));
|
|
||||||
}
|
|
||||||
return inflater.inflate(R.layout.fragment_feed, container, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onPause() {
|
|
||||||
super.onPause();
|
|
||||||
disposeEverything();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onResume() {
|
|
||||||
super.onResume();
|
|
||||||
if (wasLoading.get()) doInitialLoadLogic();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDestroy() {
|
|
||||||
super.onDestroy();
|
|
||||||
|
|
||||||
disposeEverything();
|
|
||||||
subscriptionService = null;
|
|
||||||
compositeDisposable = null;
|
|
||||||
subscriptionObserver = null;
|
|
||||||
feedSubscriber = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDestroyView() {
|
|
||||||
// Do not monitor for updates when user is not viewing the feed fragment.
|
|
||||||
// This is a waste of bandwidth.
|
|
||||||
disposeEverything();
|
|
||||||
super.onDestroyView();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setUserVisibleHint(boolean isVisibleToUser) {
|
|
||||||
super.setUserVisibleHint(isVisibleToUser);
|
|
||||||
if (activity != null && isVisibleToUser) {
|
|
||||||
setTitle(activity.getString(R.string.fragment_whats_new));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
|
|
||||||
super.onCreateOptionsMenu(menu, inflater);
|
|
||||||
|
|
||||||
ActionBar supportActionBar = activity.getSupportActionBar();
|
|
||||||
|
|
||||||
if(useAsFrontPage) {
|
|
||||||
supportActionBar.setDisplayShowTitleEnabled(true);
|
|
||||||
//supportActionBar.setDisplayShowTitleEnabled(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void reloadContent() {
|
|
||||||
resetFragment();
|
|
||||||
super.reloadContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// StateSaving
|
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void writeTo(Queue<Object> objectsToSave) {
|
|
||||||
super.writeTo(objectsToSave);
|
|
||||||
objectsToSave.add(allItemsLoaded);
|
|
||||||
objectsToSave.add(itemsLoaded);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
public void readFrom(@NonNull Queue<Object> savedObjects) throws Exception {
|
|
||||||
super.readFrom(savedObjects);
|
|
||||||
allItemsLoaded = (AtomicBoolean) savedObjects.poll();
|
|
||||||
itemsLoaded = (HashSet<String>) savedObjects.poll();
|
|
||||||
}
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Feed Loader
|
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void startLoading(boolean forceLoad) {
|
|
||||||
if (DEBUG) Log.d(TAG, "startLoading() called with: forceLoad = [" + forceLoad + "]");
|
|
||||||
if (subscriptionObserver != null) subscriptionObserver.dispose();
|
|
||||||
|
|
||||||
if (allItemsLoaded.get()) {
|
|
||||||
if (infoListAdapter.getItemsList().size() == 0) {
|
|
||||||
showEmptyState();
|
|
||||||
} else {
|
|
||||||
showListFooter(false);
|
|
||||||
hideLoading();
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.set(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading.set(true);
|
|
||||||
showLoading();
|
|
||||||
showListFooter(true);
|
|
||||||
subscriptionObserver = subscriptionService.getSubscription()
|
|
||||||
.onErrorReturnItem(Collections.emptyList())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(this::handleResult, this::onError);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void handleResult(@androidx.annotation.NonNull List<SubscriptionEntity> result) {
|
|
||||||
super.handleResult(result);
|
|
||||||
|
|
||||||
if (result.isEmpty()) {
|
|
||||||
infoListAdapter.clearStreamItemList();
|
|
||||||
showEmptyState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
subscriptionPoolSize = result.size();
|
|
||||||
Flowable.fromIterable(result)
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(getSubscriptionObserver());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Responsible for reacting to user pulling request and starting a request for new feed stream.
|
|
||||||
* <p>
|
|
||||||
* On initialization, it automatically requests the amount of feed needed to display
|
|
||||||
* a minimum amount required (FEED_LOAD_SIZE).
|
|
||||||
* <p>
|
|
||||||
* Upon receiving a user pull, it creates a Single Observer to fetch the ChannelInfo
|
|
||||||
* containing the feed streams.
|
|
||||||
**/
|
|
||||||
private Subscriber<SubscriptionEntity> getSubscriptionObserver() {
|
|
||||||
return new Subscriber<SubscriptionEntity>() {
|
|
||||||
@Override
|
|
||||||
public void onSubscribe(Subscription s) {
|
|
||||||
if (feedSubscriber != null) feedSubscriber.cancel();
|
|
||||||
feedSubscriber = s;
|
|
||||||
|
|
||||||
int requestSize = FEED_LOAD_COUNT - infoListAdapter.getItemsList().size();
|
|
||||||
if (wasLoading.getAndSet(false)) requestSize = FEED_LOAD_COUNT;
|
|
||||||
|
|
||||||
boolean hasToLoad = requestSize > 0;
|
|
||||||
if (hasToLoad) {
|
|
||||||
requestLoadedAtomic.set(infoListAdapter.getItemsList().size());
|
|
||||||
requestFeed(requestSize);
|
|
||||||
}
|
|
||||||
isLoading.set(hasToLoad);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(SubscriptionEntity subscriptionEntity) {
|
|
||||||
if (!itemsLoaded.contains(subscriptionEntity.getServiceId() + subscriptionEntity.getUrl())) {
|
|
||||||
subscriptionService.getChannelInfo(subscriptionEntity)
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.onErrorComplete(
|
|
||||||
(@io.reactivex.annotations.NonNull Throwable throwable) ->
|
|
||||||
FeedFragment.super.onError(throwable))
|
|
||||||
.subscribe(
|
|
||||||
getChannelInfoObserver(subscriptionEntity.getServiceId(),
|
|
||||||
subscriptionEntity.getUrl()));
|
|
||||||
} else {
|
|
||||||
requestFeed(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable exception) {
|
|
||||||
FeedFragment.this.onError(exception);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
if (DEBUG) Log.d(TAG, "getSubscriptionObserver > onComplete() called");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* On each request, a subscription item from the updated table is transformed
|
|
||||||
* into a ChannelInfo, containing the latest streams from the channel.
|
|
||||||
* <p>
|
|
||||||
* Currently, the feed uses the first into from the list of streams.
|
|
||||||
* <p>
|
|
||||||
* If chosen feed already displayed, then we request another feed from another
|
|
||||||
* subscription, until the subscription table runs out of new items.
|
|
||||||
* <p>
|
|
||||||
* This Observer is self-contained and will close itself when complete. However, this
|
|
||||||
* does not obey the fragment lifecycle and may continue running in the background
|
|
||||||
* until it is complete. This is done due to RxJava2 no longer propagate errors once
|
|
||||||
* an observer is unsubscribed while the thread process is still running.
|
|
||||||
* <p>
|
|
||||||
* To solve the above issue, we can either set a global RxJava Error Handler, or
|
|
||||||
* manage exceptions case by case. This should be done if the current implementation is
|
|
||||||
* too costly when dealing with larger subscription sets.
|
|
||||||
*
|
|
||||||
* @param url + serviceId to put in {@link #allItemsLoaded} to signal that this specific entity has been loaded.
|
|
||||||
*/
|
|
||||||
private MaybeObserver<ChannelInfo> getChannelInfoObserver(final int serviceId, final String url) {
|
|
||||||
return new MaybeObserver<ChannelInfo>() {
|
|
||||||
private Disposable observer;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSubscribe(Disposable d) {
|
|
||||||
observer = d;
|
|
||||||
compositeDisposable.add(d);
|
|
||||||
isLoading.set(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called only when response is non-empty
|
|
||||||
@Override
|
|
||||||
public void onSuccess(final ChannelInfo channelInfo) {
|
|
||||||
if (infoListAdapter == null || channelInfo.getRelatedItems().isEmpty()) {
|
|
||||||
onDone();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final InfoItem item = channelInfo.getRelatedItems().get(0);
|
|
||||||
// Keep requesting new items if the current one already exists
|
|
||||||
boolean itemExists = doesItemExist(infoListAdapter.getItemsList(), item);
|
|
||||||
if (!itemExists) {
|
|
||||||
infoListAdapter.addInfoItem(item);
|
|
||||||
//updateSubscription(channelInfo);
|
|
||||||
} else {
|
|
||||||
requestFeed(1);
|
|
||||||
}
|
|
||||||
onDone();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable exception) {
|
|
||||||
showSnackBarError(exception,
|
|
||||||
UserAction.SUBSCRIPTION,
|
|
||||||
NewPipe.getNameOfService(serviceId),
|
|
||||||
url, 0);
|
|
||||||
requestFeed(1);
|
|
||||||
onDone();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called only when response is empty
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
onDone();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onDone() {
|
|
||||||
if (observer.isDisposed()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
itemsLoaded.add(serviceId + url);
|
|
||||||
compositeDisposable.remove(observer);
|
|
||||||
|
|
||||||
int loaded = requestLoadedAtomic.incrementAndGet();
|
|
||||||
if (loaded >= Math.min(FEED_LOAD_COUNT, subscriptionPoolSize)) {
|
|
||||||
requestLoadedAtomic.set(0);
|
|
||||||
isLoading.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemsLoaded.size() == subscriptionPoolSize) {
|
|
||||||
if (DEBUG) Log.d(TAG, "getChannelInfoObserver > All Items Loaded");
|
|
||||||
allItemsLoaded.set(true);
|
|
||||||
showListFooter(false);
|
|
||||||
isLoading.set(false);
|
|
||||||
hideLoading();
|
|
||||||
if (infoListAdapter.getItemsList().size() == 0) {
|
|
||||||
showEmptyState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void loadMoreItems() {
|
|
||||||
isLoading.set(true);
|
|
||||||
delayHandler.removeCallbacksAndMessages(null);
|
|
||||||
// Add a little of a delay when requesting more items because the cache is so fast,
|
|
||||||
// that the view seems stuck to the user when he scroll to the bottom
|
|
||||||
delayHandler.postDelayed(() -> requestFeed(FEED_LOAD_COUNT), 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected boolean hasMoreItems() {
|
|
||||||
return !allItemsLoaded.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
private final Handler delayHandler = new Handler();
|
|
||||||
|
|
||||||
private void requestFeed(final int count) {
|
|
||||||
if (DEBUG) Log.d(TAG, "requestFeed() called with: count = [" + count + "], feedSubscriber = [" + feedSubscriber + "]");
|
|
||||||
if (feedSubscriber == null) return;
|
|
||||||
|
|
||||||
isLoading.set(true);
|
|
||||||
delayHandler.removeCallbacksAndMessages(null);
|
|
||||||
feedSubscriber.request(count);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Utils
|
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
private void resetFragment() {
|
|
||||||
if (DEBUG) Log.d(TAG, "resetFragment() called");
|
|
||||||
if (subscriptionObserver != null) subscriptionObserver.dispose();
|
|
||||||
if (compositeDisposable != null) compositeDisposable.clear();
|
|
||||||
if (infoListAdapter != null) infoListAdapter.clearStreamItemList();
|
|
||||||
|
|
||||||
delayHandler.removeCallbacksAndMessages(null);
|
|
||||||
requestLoadedAtomic.set(0);
|
|
||||||
allItemsLoaded.set(false);
|
|
||||||
showListFooter(false);
|
|
||||||
itemsLoaded.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void disposeEverything() {
|
|
||||||
if (subscriptionObserver != null) subscriptionObserver.dispose();
|
|
||||||
if (compositeDisposable != null) compositeDisposable.clear();
|
|
||||||
if (feedSubscriber != null) feedSubscriber.cancel();
|
|
||||||
delayHandler.removeCallbacksAndMessages(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean doesItemExist(final List<InfoItem> items, final InfoItem item) {
|
|
||||||
for (final InfoItem existingItem : items) {
|
|
||||||
if (existingItem.getInfoType() == item.getInfoType() &&
|
|
||||||
existingItem.getServiceId() == item.getServiceId() &&
|
|
||||||
existingItem.getName().equals(item.getName()) &&
|
|
||||||
existingItem.getUrl().equals(item.getUrl())) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private int howManyItemsToLoad() {
|
|
||||||
int heightPixels = getResources().getDisplayMetrics().heightPixels;
|
|
||||||
int itemHeightPixels = activity.getResources().getDimensionPixelSize(R.dimen.video_item_search_height);
|
|
||||||
|
|
||||||
int items = itemHeightPixels > 0
|
|
||||||
? heightPixels / itemHeightPixels + OFF_SCREEN_ITEMS_COUNT
|
|
||||||
: MIN_ITEMS_INITIAL_LOAD;
|
|
||||||
return Math.max(MIN_ITEMS_INITIAL_LOAD, items);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Fragment Error Handling
|
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void showError(String message, boolean showRetryButton) {
|
|
||||||
resetFragment();
|
|
||||||
super.showError(message, showRetryButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected boolean onError(Throwable exception) {
|
|
||||||
if (super.onError(exception)) return true;
|
|
||||||
|
|
||||||
int errorId = exception instanceof ExtractionException
|
|
||||||
? R.string.parsing_error
|
|
||||||
: R.string.general_error;
|
|
||||||
onUnrecoverableError(exception,
|
|
||||||
UserAction.SOMETHING_ELSE,
|
|
||||||
"none",
|
|
||||||
"Requesting feed",
|
|
||||||
errorId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
327
app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt
Normal file
327
app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt
Normal file
|
@ -0,0 +1,327 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Mauricio Colli <mauriciocolli@outlook.com>
|
||||||
|
* FeedFragment.kt is part of NewPipe
|
||||||
|
*
|
||||||
|
* License: GPL-3.0+
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.schabi.newpipe.local.feed
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Parcelable
|
||||||
|
import android.view.*
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.lifecycle.ViewModelProviders
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import icepick.State
|
||||||
|
import kotlinx.android.synthetic.main.error_retry.*
|
||||||
|
import kotlinx.android.synthetic.main.fragment_feed.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
|
import org.schabi.newpipe.fragments.list.BaseListFragment
|
||||||
|
import org.schabi.newpipe.local.feed.service.FeedLoadService
|
||||||
|
import org.schabi.newpipe.report.UserAction
|
||||||
|
import org.schabi.newpipe.util.AnimationUtils.animateView
|
||||||
|
import org.schabi.newpipe.util.Localization
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class FeedFragment : BaseListFragment<FeedState, Unit>() {
|
||||||
|
private lateinit var viewModel: FeedViewModel
|
||||||
|
@State @JvmField var listState: Parcelable? = null
|
||||||
|
|
||||||
|
private var groupId = FeedGroupEntity.GROUP_ALL_ID
|
||||||
|
private var groupName = ""
|
||||||
|
private var oldestSubscriptionUpdate: Calendar? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
setHasOptionsMenu(true)
|
||||||
|
useDefaultStateSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
groupId = arguments?.getLong(KEY_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID) ?: FeedGroupEntity.GROUP_ALL_ID
|
||||||
|
groupName = arguments?.getString(KEY_GROUP_NAME) ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||||
|
return inflater.inflate(R.layout.fragment_feed, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(rootView, savedInstanceState)
|
||||||
|
|
||||||
|
viewModel = ViewModelProviders.of(this, FeedViewModel.Factory(requireContext(), groupId)).get(FeedViewModel::class.java)
|
||||||
|
viewModel.stateLiveData.observe(viewLifecycleOwner, Observer { it?.let(::handleResult) })
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
listState = items_list?.layoutManager?.onSaveInstanceState()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
updateRelativeTimeViews()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setUserVisibleHint(isVisibleToUser: Boolean) {
|
||||||
|
super.setUserVisibleHint(isVisibleToUser)
|
||||||
|
|
||||||
|
if (!isVisibleToUser && view != null) {
|
||||||
|
updateRelativeTimeViews()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun initListeners() {
|
||||||
|
super.initListeners()
|
||||||
|
refresh_root_view.setOnClickListener {
|
||||||
|
triggerUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Menu
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
|
super.onCreateOptionsMenu(menu, inflater)
|
||||||
|
activity.supportActionBar?.setTitle(R.string.fragment_feed_title)
|
||||||
|
activity.supportActionBar?.subtitle = groupName
|
||||||
|
|
||||||
|
inflater.inflate(R.menu.menu_feed_fragment, menu)
|
||||||
|
|
||||||
|
if (useAsFrontPage) {
|
||||||
|
menu.findItem(R.id.menu_item_feed_help).setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
|
if (item.itemId == R.id.menu_item_feed_help) {
|
||||||
|
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||||
|
|
||||||
|
val usingDedicatedMethod = sharedPreferences.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false)
|
||||||
|
val enableDisableButtonText = when {
|
||||||
|
usingDedicatedMethod -> R.string.feed_use_dedicated_fetch_method_disable_button
|
||||||
|
else -> R.string.feed_use_dedicated_fetch_method_enable_button
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertDialog.Builder(requireContext())
|
||||||
|
.setMessage(R.string.feed_use_dedicated_fetch_method_help_text)
|
||||||
|
.setNeutralButton(enableDisableButtonText) { _, _ ->
|
||||||
|
sharedPreferences.edit()
|
||||||
|
.putBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), !usingDedicatedMethod)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
.setPositiveButton(resources.getString(R.string.finish), null)
|
||||||
|
.create()
|
||||||
|
.show()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.onOptionsItemSelected(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyOptionsMenu() {
|
||||||
|
super.onDestroyOptionsMenu()
|
||||||
|
activity?.supportActionBar?.subtitle = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
activity?.supportActionBar?.subtitle = null
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Handling
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
override fun showLoading() {
|
||||||
|
animateView(refresh_root_view, false, 0)
|
||||||
|
animateView(items_list, false, 0)
|
||||||
|
|
||||||
|
animateView(loading_progress_bar, true, 200)
|
||||||
|
animateView(loading_progress_text, true, 200)
|
||||||
|
|
||||||
|
empty_state_view?.let { animateView(it, false, 0) }
|
||||||
|
animateView(error_panel, false, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hideLoading() {
|
||||||
|
animateView(refresh_root_view, true, 200)
|
||||||
|
animateView(items_list, true, 300)
|
||||||
|
|
||||||
|
animateView(loading_progress_bar, false, 0)
|
||||||
|
animateView(loading_progress_text, false, 0)
|
||||||
|
|
||||||
|
empty_state_view?.let { animateView(it, false, 0) }
|
||||||
|
animateView(error_panel, false, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun showEmptyState() {
|
||||||
|
animateView(refresh_root_view, true, 200)
|
||||||
|
animateView(items_list, false, 0)
|
||||||
|
|
||||||
|
animateView(loading_progress_bar, false, 0)
|
||||||
|
animateView(loading_progress_text, false, 0)
|
||||||
|
|
||||||
|
empty_state_view?.let { animateView(it, true, 800) }
|
||||||
|
animateView(error_panel, false, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun showError(message: String, showRetryButton: Boolean) {
|
||||||
|
infoListAdapter.clearStreamItemList()
|
||||||
|
animateView(refresh_root_view, false, 120)
|
||||||
|
animateView(items_list, false, 120)
|
||||||
|
|
||||||
|
animateView(loading_progress_bar, false, 120)
|
||||||
|
animateView(loading_progress_text, false, 120)
|
||||||
|
|
||||||
|
error_message_view.text = message
|
||||||
|
animateView(error_button_retry, showRetryButton, if (showRetryButton) 600 else 0)
|
||||||
|
animateView(error_panel, true, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleResult(result: FeedState) {
|
||||||
|
when (result) {
|
||||||
|
is FeedState.ProgressState -> handleProgressState(result)
|
||||||
|
is FeedState.LoadedState -> handleLoadedState(result)
|
||||||
|
is FeedState.ErrorState -> if (handleErrorState(result)) return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRefreshViewState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleProgressState(progressState: FeedState.ProgressState) {
|
||||||
|
showLoading()
|
||||||
|
|
||||||
|
val isIndeterminate = progressState.currentProgress == -1 &&
|
||||||
|
progressState.maxProgress == -1
|
||||||
|
|
||||||
|
if (!isIndeterminate) {
|
||||||
|
loading_progress_text.text = "${progressState.currentProgress}/${progressState.maxProgress}"
|
||||||
|
} else if (progressState.progressMessage > 0) {
|
||||||
|
loading_progress_text?.setText(progressState.progressMessage)
|
||||||
|
} else {
|
||||||
|
loading_progress_text?.text = "∞/∞"
|
||||||
|
}
|
||||||
|
|
||||||
|
loading_progress_bar.isIndeterminate = isIndeterminate ||
|
||||||
|
(progressState.maxProgress > 0 && progressState.currentProgress == 0)
|
||||||
|
loading_progress_bar.progress = progressState.currentProgress
|
||||||
|
|
||||||
|
loading_progress_bar.max = progressState.maxProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleLoadedState(loadedState: FeedState.LoadedState) {
|
||||||
|
infoListAdapter.setInfoItemList(loadedState.items)
|
||||||
|
listState?.run {
|
||||||
|
items_list.layoutManager?.onRestoreInstanceState(listState)
|
||||||
|
listState = null
|
||||||
|
}
|
||||||
|
|
||||||
|
oldestSubscriptionUpdate = loadedState.oldestUpdate
|
||||||
|
|
||||||
|
if (loadedState.notLoadedCount > 0) {
|
||||||
|
refresh_subtitle_text.visibility = View.VISIBLE
|
||||||
|
refresh_subtitle_text.text = getString(R.string.feed_subscription_not_loaded_count, loadedState.notLoadedCount)
|
||||||
|
} else {
|
||||||
|
refresh_subtitle_text.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadedState.itemsErrors.isNotEmpty()) {
|
||||||
|
showSnackBarError(loadedState.itemsErrors, UserAction.REQUESTED_FEED,
|
||||||
|
"none", "Loading feed", R.string.general_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadedState.items.isEmpty()) {
|
||||||
|
showEmptyState()
|
||||||
|
} else {
|
||||||
|
hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun handleErrorState(errorState: FeedState.ErrorState): Boolean {
|
||||||
|
hideLoading()
|
||||||
|
errorState.error?.let {
|
||||||
|
onError(errorState.error)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateRelativeTimeViews() {
|
||||||
|
updateRefreshViewState()
|
||||||
|
infoListAdapter.notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateRefreshViewState() {
|
||||||
|
val oldestSubscriptionUpdateText = when {
|
||||||
|
oldestSubscriptionUpdate != null -> Localization.relativeTime(oldestSubscriptionUpdate!!)
|
||||||
|
else -> "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh_text?.text = getString(R.string.feed_oldest_subscription_update, oldestSubscriptionUpdateText)
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Load Service Handling
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
override fun doInitialLoadLogic() {}
|
||||||
|
override fun reloadContent() = triggerUpdate()
|
||||||
|
override fun loadMoreItems() {}
|
||||||
|
override fun hasMoreItems() = false
|
||||||
|
|
||||||
|
private fun triggerUpdate() {
|
||||||
|
getActivity()?.startService(Intent(requireContext(), FeedLoadService::class.java).apply {
|
||||||
|
putExtra(FeedLoadService.EXTRA_GROUP_ID, groupId)
|
||||||
|
})
|
||||||
|
listState = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(exception: Throwable): Boolean {
|
||||||
|
if (super.onError(exception)) return true
|
||||||
|
|
||||||
|
if (useAsFrontPage) {
|
||||||
|
showSnackBarError(exception, UserAction.REQUESTED_FEED, "none", "Loading Feed", 0)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnrecoverableError(exception, UserAction.REQUESTED_FEED, "none", "Loading Feed", 0)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val KEY_GROUP_ID = "ARG_GROUP_ID"
|
||||||
|
const val KEY_GROUP_NAME = "ARG_GROUP_NAME"
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun newInstance(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, groupName: String? = null): FeedFragment {
|
||||||
|
val feedFragment = FeedFragment()
|
||||||
|
|
||||||
|
feedFragment.arguments = Bundle().apply {
|
||||||
|
putLong(KEY_GROUP_ID, groupId)
|
||||||
|
putString(KEY_GROUP_NAME, groupName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return feedFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
24
app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt
Normal file
24
app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
package org.schabi.newpipe.local.feed
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
sealed class FeedState {
|
||||||
|
data class ProgressState(
|
||||||
|
val currentProgress: Int = -1,
|
||||||
|
val maxProgress: Int = -1,
|
||||||
|
@StringRes val progressMessage: Int = 0
|
||||||
|
) : FeedState()
|
||||||
|
|
||||||
|
data class LoadedState(
|
||||||
|
val items: List<StreamInfoItem>,
|
||||||
|
val oldestUpdate: Calendar? = null,
|
||||||
|
val notLoadedCount: Long,
|
||||||
|
val itemsErrors: List<Throwable> = emptyList()
|
||||||
|
) : FeedState()
|
||||||
|
|
||||||
|
data class ErrorState(
|
||||||
|
val error: Throwable? = null
|
||||||
|
) : FeedState()
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
package org.schabi.newpipe.local.feed
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.functions.Function4
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
import org.schabi.newpipe.local.feed.service.FeedEventManager
|
||||||
|
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.*
|
||||||
|
import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModel() {
|
||||||
|
class Factory(val context: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||||
|
return FeedViewModel(context.applicationContext, groupId) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
|
||||||
|
|
||||||
|
private val mutableStateLiveData = MutableLiveData<FeedState>()
|
||||||
|
val stateLiveData: LiveData<FeedState> = mutableStateLiveData
|
||||||
|
|
||||||
|
private var combineDisposable = Flowable
|
||||||
|
.combineLatest(
|
||||||
|
FeedEventManager.events(),
|
||||||
|
feedDatabaseManager.asStreamItems(groupId),
|
||||||
|
feedDatabaseManager.notLoadedCount(groupId),
|
||||||
|
feedDatabaseManager.oldestSubscriptionUpdate(groupId),
|
||||||
|
|
||||||
|
Function4 { t1: FeedEventManager.Event, t2: List<StreamInfoItem>, t3: Long, t4: List<Date> ->
|
||||||
|
return@Function4 CombineResultHolder(t1, t2, t3, t4.firstOrNull())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe {
|
||||||
|
val (event, listFromDB, notLoadedCount, oldestUpdate) = it
|
||||||
|
|
||||||
|
val oldestUpdateCalendar =
|
||||||
|
oldestUpdate?.let { Calendar.getInstance().apply { time = it } }
|
||||||
|
|
||||||
|
mutableStateLiveData.postValue(when (event) {
|
||||||
|
is IdleEvent -> FeedState.LoadedState(listFromDB, oldestUpdateCalendar, notLoadedCount)
|
||||||
|
is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage)
|
||||||
|
is SuccessResultEvent -> FeedState.LoadedState(listFromDB, oldestUpdateCalendar, notLoadedCount, event.itemsErrors)
|
||||||
|
is ErrorResultEvent -> FeedState.ErrorState(event.error)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (event is ErrorResultEvent || event is SuccessResultEvent) {
|
||||||
|
FeedEventManager.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
combineDisposable.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List<StreamInfoItem>, val t3: Long, val t4: Date?)
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
package org.schabi.newpipe.local.feed.service
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import io.reactivex.processors.BehaviorProcessor
|
||||||
|
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
object FeedEventManager {
|
||||||
|
private var processor: BehaviorProcessor<Event> = BehaviorProcessor.create()
|
||||||
|
private var ignoreUpstream = AtomicBoolean()
|
||||||
|
private var eventsFlowable = processor.startWith(IdleEvent)
|
||||||
|
|
||||||
|
fun postEvent(event: Event) {
|
||||||
|
processor.onNext(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun events(): Flowable<Event> {
|
||||||
|
return eventsFlowable.filter { !ignoreUpstream.get() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
ignoreUpstream.set(true)
|
||||||
|
postEvent(IdleEvent)
|
||||||
|
ignoreUpstream.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Event {
|
||||||
|
object IdleEvent : Event()
|
||||||
|
data class ProgressEvent(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : Event() {
|
||||||
|
constructor(@StringRes progressMessage: Int) : this(-1, -1, progressMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SuccessResultEvent(val itemsErrors: List<Throwable> = emptyList()) : Event()
|
||||||
|
data class ErrorResultEvent(val error: Throwable) : Event()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,464 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2019 Mauricio Colli <mauriciocolli@outlook.com>
|
||||||
|
* FeedLoadService.kt is part of NewPipe
|
||||||
|
*
|
||||||
|
* License: GPL-3.0+
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* 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/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.schabi.newpipe.local.feed.service
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.preference.PreferenceManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import io.reactivex.Notification
|
||||||
|
import io.reactivex.Single
|
||||||
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.disposables.CompositeDisposable
|
||||||
|
import io.reactivex.functions.Consumer
|
||||||
|
import io.reactivex.functions.Function
|
||||||
|
import io.reactivex.processors.PublishProcessor
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import org.reactivestreams.Subscriber
|
||||||
|
import org.reactivestreams.Subscription
|
||||||
|
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.exceptions.ReCaptchaException
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||||
|
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.*
|
||||||
|
import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
|
||||||
|
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||||
|
import org.schabi.newpipe.util.ExtractorHelper
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
|
class FeedLoadService : Service() {
|
||||||
|
companion object {
|
||||||
|
private val TAG = FeedLoadService::class.java.simpleName
|
||||||
|
private const val NOTIFICATION_ID = 7293450
|
||||||
|
private const val ACTION_CANCEL = "org.schabi.newpipe.local.feed.service.FeedLoadService.CANCEL"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often the notification will be updated.
|
||||||
|
*/
|
||||||
|
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 lateinit var feedDatabaseManager: FeedDatabaseManager
|
||||||
|
private lateinit var feedResultsHolder: ResultsHolder
|
||||||
|
|
||||||
|
private var disposables = CompositeDisposable()
|
||||||
|
private var notificationUpdater = PublishProcessor.create<String>()
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Lifecycle
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
subscriptionManager = SubscriptionManager(this)
|
||||||
|
feedDatabaseManager = FeedDatabaseManager(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "onStartCommand() called with: intent = [" + intent + "]," +
|
||||||
|
" flags = [" + flags + "], startId = [" + startId + "]")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent == null || loadingSubscription != 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)
|
||||||
|
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disposeAll() {
|
||||||
|
unregisterReceiver(broadcastReceiver)
|
||||||
|
|
||||||
|
loadingSubscription?.cancel()
|
||||||
|
loadingSubscription = null
|
||||||
|
|
||||||
|
disposables.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopService() {
|
||||||
|
disposeAll()
|
||||||
|
stopForeground(true)
|
||||||
|
notificationManager.cancel(NOTIFICATION_ID)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent): IBinder? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Loading & Handling
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
private class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) {
|
||||||
|
companion object {
|
||||||
|
fun wrapList(subscriptionId: Long, info: ListInfo<StreamInfoItem>): List<Throwable> {
|
||||||
|
val toReturn = ArrayList<Throwable>(info.errors.size)
|
||||||
|
for (error in info.errors) {
|
||||||
|
toReturn.add(RequestException(subscriptionId, info.serviceId.toString() + ":" + info.url, error))
|
||||||
|
}
|
||||||
|
return toReturn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startLoading(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, useFeedExtractor: Boolean, thresholdOutdatedSeconds: Int) {
|
||||||
|
feedResultsHolder = ResultsHolder()
|
||||||
|
|
||||||
|
val outdatedThreshold = Calendar.getInstance().apply {
|
||||||
|
add(Calendar.SECOND, -thresholdOutdatedSeconds)
|
||||||
|
}.time
|
||||||
|
|
||||||
|
val subscriptions = when (groupId) {
|
||||||
|
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
|
||||||
|
else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptions
|
||||||
|
.limit(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 ->
|
||||||
|
try {
|
||||||
|
val listInfo = if (useFeedExtractor) {
|
||||||
|
ExtractorHelper
|
||||||
|
.getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url)
|
||||||
|
.blockingGet()
|
||||||
|
} else {
|
||||||
|
ExtractorHelper
|
||||||
|
.getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true)
|
||||||
|
.blockingGet()
|
||||||
|
} as ListInfo<StreamInfoItem>
|
||||||
|
|
||||||
|
return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo))
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
|
||||||
|
val wrapper = RequestException(subscriptionEntity.uid, request, e)
|
||||||
|
return@map Notification.createOnError<Pair<Long, ListInfo<StreamInfoItem>>>(wrapper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sequential()
|
||||||
|
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.doOnNext(errorHandlingConsumer)
|
||||||
|
|
||||||
|
.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(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 ->
|
||||||
|
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 errorHandlingConsumer: Consumer<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>
|
||||||
|
get() = Consumer {
|
||||||
|
if (it.isOnError) {
|
||||||
|
var error = it.error!!
|
||||||
|
if (error is RequestException) error = error.cause!!
|
||||||
|
val cause = error.cause
|
||||||
|
|
||||||
|
when {
|
||||||
|
error is IOException -> throw error
|
||||||
|
cause is IOException -> throw cause
|
||||||
|
|
||||||
|
error is ReCaptchaException -> throw error
|
||||||
|
cause is ReCaptchaException -> throw cause
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
|
||||||
|
.setOngoing(true)
|
||||||
|
.setProgress(-1, -1, true)
|
||||||
|
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.addAction(0, getString(R.string.cancel), cancelActionIntent)
|
||||||
|
.setContentTitle(getString(R.string.feed_notification_loading))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupNotification() {
|
||||||
|
notificationManager = NotificationManagerCompat.from(this)
|
||||||
|
notificationBuilder = createNotification()
|
||||||
|
|
||||||
|
val throttleAfterFirstEmission = Function { flow: Flowable<String> ->
|
||||||
|
flow.limit(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS))
|
||||||
|
}
|
||||||
|
|
||||||
|
disposables.add(notificationUpdater
|
||||||
|
.publish(throttleAfterFirstEmission)
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(this::updateNotificationProgress))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotificationProgress(updateDescription: String?) {
|
||||||
|
notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1)
|
||||||
|
|
||||||
|
if (maxProgress.get() == -1) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null)
|
||||||
|
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
|
||||||
|
notificationBuilder.setContentText(updateDescription)
|
||||||
|
} else {
|
||||||
|
val progressText = this.currentProgress.toString() + "/" + maxProgress
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText("$updateDescription ($progressText)")
|
||||||
|
} else {
|
||||||
|
notificationBuilder.setContentInfo(progressText)
|
||||||
|
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Notification Actions
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registerReceiver(broadcastReceiver, IntentFilter(ACTION_CANCEL))
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Error handling
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
private fun handleError(error: Throwable) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -269,11 +269,11 @@ public class HistoryRecordManager {
|
||||||
for (LocalItem item : items) {
|
for (LocalItem item : items) {
|
||||||
long streamId;
|
long streamId;
|
||||||
if (item instanceof StreamStatisticsEntry) {
|
if (item instanceof StreamStatisticsEntry) {
|
||||||
streamId = ((StreamStatisticsEntry) item).streamId;
|
streamId = ((StreamStatisticsEntry) item).getStreamId();
|
||||||
} else if (item instanceof PlaylistStreamEntity) {
|
} else if (item instanceof PlaylistStreamEntity) {
|
||||||
streamId = ((PlaylistStreamEntity) item).getStreamUid();
|
streamId = ((PlaylistStreamEntity) item).getStreamUid();
|
||||||
} else if (item instanceof PlaylistStreamEntry) {
|
} else if (item instanceof PlaylistStreamEntry) {
|
||||||
streamId = ((PlaylistStreamEntry) item).streamId;
|
streamId = ((PlaylistStreamEntry) item).getStreamId();
|
||||||
} else {
|
} else {
|
||||||
result.add(null);
|
result.add(null);
|
||||||
continue;
|
continue;
|
||||||
|
|
|
@ -76,11 +76,11 @@ public class StatisticsPlaylistFragment
|
||||||
switch (sortMode) {
|
switch (sortMode) {
|
||||||
case LAST_PLAYED:
|
case LAST_PLAYED:
|
||||||
Collections.sort(results, (left, right) ->
|
Collections.sort(results, (left, right) ->
|
||||||
right.latestAccessDate.compareTo(left.latestAccessDate));
|
right.getLatestAccessDate().compareTo(left.getLatestAccessDate()));
|
||||||
return results;
|
return results;
|
||||||
case MOST_PLAYED:
|
case MOST_PLAYED:
|
||||||
Collections.sort(results, (left, right) ->
|
Collections.sort(results, (left, right) ->
|
||||||
Long.compare(right.watchCount, left.watchCount));
|
Long.compare(right.getWatchCount(), left.getWatchCount()));
|
||||||
return results;
|
return results;
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
|
@ -153,9 +153,9 @@ public class StatisticsPlaylistFragment
|
||||||
if (selectedItem instanceof StreamStatisticsEntry) {
|
if (selectedItem instanceof StreamStatisticsEntry) {
|
||||||
final StreamStatisticsEntry item = (StreamStatisticsEntry) selectedItem;
|
final StreamStatisticsEntry item = (StreamStatisticsEntry) selectedItem;
|
||||||
NavigationHelper.openVideoDetailFragment(getFM(),
|
NavigationHelper.openVideoDetailFragment(getFM(),
|
||||||
item.serviceId,
|
item.getStreamEntity().getServiceId(),
|
||||||
item.url,
|
item.getStreamEntity().getUrl(),
|
||||||
item.title);
|
item.getStreamEntity().getTitle());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -402,7 +402,7 @@ public class StatisticsPlaylistFragment
|
||||||
.get(index);
|
.get(index);
|
||||||
if(infoItem instanceof StreamStatisticsEntry) {
|
if(infoItem instanceof StreamStatisticsEntry) {
|
||||||
final StreamStatisticsEntry entry = (StreamStatisticsEntry) infoItem;
|
final StreamStatisticsEntry entry = (StreamStatisticsEntry) infoItem;
|
||||||
final Disposable onDelete = recordManager.deleteStreamHistory(entry.streamId)
|
final Disposable onDelete = recordManager.deleteStreamHistory(entry.getStreamId())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(
|
.subscribe(
|
||||||
howManyDeleted -> {
|
howManyDeleted -> {
|
||||||
|
|
|
@ -52,12 +52,12 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
|
||||||
if (!(localItem instanceof PlaylistStreamEntry)) return;
|
if (!(localItem instanceof PlaylistStreamEntry)) return;
|
||||||
final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem;
|
final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem;
|
||||||
|
|
||||||
itemVideoTitleView.setText(item.title);
|
itemVideoTitleView.setText(item.getStreamEntity().getTitle());
|
||||||
itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.uploader,
|
itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.getStreamEntity().getUploader(),
|
||||||
NewPipe.getNameOfService(item.serviceId)));
|
NewPipe.getNameOfService(item.getStreamEntity().getServiceId())));
|
||||||
|
|
||||||
if (item.duration > 0) {
|
if (item.getStreamEntity().getDuration() > 0) {
|
||||||
itemDurationView.setText(Localization.getDurationString(item.duration));
|
itemDurationView.setText(Localization.getDurationString(item.getStreamEntity().getDuration()));
|
||||||
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
|
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
|
||||||
R.color.duration_background_color));
|
R.color.duration_background_color));
|
||||||
itemDurationView.setVisibility(View.VISIBLE);
|
itemDurationView.setVisibility(View.VISIBLE);
|
||||||
|
@ -65,7 +65,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
|
||||||
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{ add(localItem); }}).blockingGet().get(0);
|
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{ add(localItem); }}).blockingGet().get(0);
|
||||||
if (state != null) {
|
if (state != null) {
|
||||||
itemProgressView.setVisibility(View.VISIBLE);
|
itemProgressView.setVisibility(View.VISIBLE);
|
||||||
itemProgressView.setMax((int) item.duration);
|
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
|
||||||
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
|
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
|
||||||
} else {
|
} else {
|
||||||
itemProgressView.setVisibility(View.GONE);
|
itemProgressView.setVisibility(View.GONE);
|
||||||
|
@ -75,7 +75,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default thumbnail is shown on error, while loading and if the url is empty
|
// Default thumbnail is shown on error, while loading and if the url is empty
|
||||||
itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView,
|
itemBuilder.displayImage(item.getStreamEntity().getThumbnailUrl(), itemThumbnailView,
|
||||||
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS);
|
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS);
|
||||||
|
|
||||||
itemView.setOnClickListener(view -> {
|
itemView.setOnClickListener(view -> {
|
||||||
|
@ -102,8 +102,8 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
|
||||||
final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem;
|
final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem;
|
||||||
|
|
||||||
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{ add(localItem); }}).blockingGet().get(0);
|
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{ add(localItem); }}).blockingGet().get(0);
|
||||||
if (state != null && item.duration > 0) {
|
if (state != null && item.getStreamEntity().getDuration() > 0) {
|
||||||
itemProgressView.setMax((int) item.duration);
|
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
|
||||||
if (itemProgressView.getVisibility() == View.VISIBLE) {
|
if (itemProgressView.getVisibility() == View.VISIBLE) {
|
||||||
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
|
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -71,9 +71,9 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
|
||||||
private String getStreamInfoDetailLine(final StreamStatisticsEntry entry,
|
private String getStreamInfoDetailLine(final StreamStatisticsEntry entry,
|
||||||
final DateFormat dateFormat) {
|
final DateFormat dateFormat) {
|
||||||
final String watchCount = Localization.shortViewCount(itemBuilder.getContext(),
|
final String watchCount = Localization.shortViewCount(itemBuilder.getContext(),
|
||||||
entry.watchCount);
|
entry.getWatchCount());
|
||||||
final String uploadDate = dateFormat.format(entry.latestAccessDate);
|
final String uploadDate = dateFormat.format(entry.getLatestAccessDate());
|
||||||
final String serviceName = NewPipe.getNameOfService(entry.serviceId);
|
final String serviceName = NewPipe.getNameOfService(entry.getStreamEntity().getServiceId());
|
||||||
return Localization.concatenateStrings(watchCount, uploadDate, serviceName);
|
return Localization.concatenateStrings(watchCount, uploadDate, serviceName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,11 +82,11 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
|
||||||
if (!(localItem instanceof StreamStatisticsEntry)) return;
|
if (!(localItem instanceof StreamStatisticsEntry)) return;
|
||||||
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
|
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
|
||||||
|
|
||||||
itemVideoTitleView.setText(item.title);
|
itemVideoTitleView.setText(item.getStreamEntity().getTitle());
|
||||||
itemUploaderView.setText(item.uploader);
|
itemUploaderView.setText(item.getStreamEntity().getUploader());
|
||||||
|
|
||||||
if (item.duration > 0) {
|
if (item.getStreamEntity().getDuration() > 0) {
|
||||||
itemDurationView.setText(Localization.getDurationString(item.duration));
|
itemDurationView.setText(Localization.getDurationString(item.getStreamEntity().getDuration()));
|
||||||
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
|
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
|
||||||
R.color.duration_background_color));
|
R.color.duration_background_color));
|
||||||
itemDurationView.setVisibility(View.VISIBLE);
|
itemDurationView.setVisibility(View.VISIBLE);
|
||||||
|
@ -94,7 +94,7 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
|
||||||
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{ add(localItem); }}).blockingGet().get(0);
|
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{ add(localItem); }}).blockingGet().get(0);
|
||||||
if (state != null) {
|
if (state != null) {
|
||||||
itemProgressView.setVisibility(View.VISIBLE);
|
itemProgressView.setVisibility(View.VISIBLE);
|
||||||
itemProgressView.setMax((int) item.duration);
|
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
|
||||||
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
|
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
|
||||||
} else {
|
} else {
|
||||||
itemProgressView.setVisibility(View.GONE);
|
itemProgressView.setVisibility(View.GONE);
|
||||||
|
@ -109,7 +109,7 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default thumbnail is shown on error, while loading and if the url is empty
|
// Default thumbnail is shown on error, while loading and if the url is empty
|
||||||
itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView,
|
itemBuilder.displayImage(item.getStreamEntity().getThumbnailUrl(), itemThumbnailView,
|
||||||
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS);
|
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS);
|
||||||
|
|
||||||
itemView.setOnClickListener(view -> {
|
itemView.setOnClickListener(view -> {
|
||||||
|
@ -133,8 +133,8 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
|
||||||
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
|
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
|
||||||
|
|
||||||
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{ add(localItem); }}).blockingGet().get(0);
|
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{ add(localItem); }}).blockingGet().get(0);
|
||||||
if (state != null && item.duration > 0) {
|
if (state != null && item.getStreamEntity().getDuration() > 0) {
|
||||||
itemProgressView.setMax((int) item.duration);
|
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
|
||||||
if (itemProgressView.getVisibility() == View.VISIBLE) {
|
if (itemProgressView.getVisibility() == View.VISIBLE) {
|
||||||
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
|
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -168,7 +168,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||||
if (selectedItem instanceof PlaylistStreamEntry) {
|
if (selectedItem instanceof PlaylistStreamEntry) {
|
||||||
final PlaylistStreamEntry item = (PlaylistStreamEntry) selectedItem;
|
final PlaylistStreamEntry item = (PlaylistStreamEntry) selectedItem;
|
||||||
NavigationHelper.openVideoDetailFragment(getFragmentManager(),
|
NavigationHelper.openVideoDetailFragment(getFragmentManager(),
|
||||||
item.serviceId, item.url, item.title);
|
item.getStreamEntity().getServiceId(), item.getStreamEntity().getUrl(), item.getStreamEntity().getTitle());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -422,7 +422,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||||
String newThumbnailUrl;
|
String newThumbnailUrl;
|
||||||
|
|
||||||
if (!itemListAdapter.getItemsList().isEmpty()) {
|
if (!itemListAdapter.getItemsList().isEmpty()) {
|
||||||
newThumbnailUrl = ((PlaylistStreamEntry) itemListAdapter.getItemsList().get(0)).thumbnailUrl;
|
newThumbnailUrl = ((PlaylistStreamEntry) itemListAdapter.getItemsList().get(0)).getStreamEntity().getThumbnailUrl();
|
||||||
} else {
|
} else {
|
||||||
newThumbnailUrl = "drawable://" + R.drawable.dummy_thumbnail_playlist;
|
newThumbnailUrl = "drawable://" + R.drawable.dummy_thumbnail_playlist;
|
||||||
}
|
}
|
||||||
|
@ -434,7 +434,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||||
if (itemListAdapter == null) return;
|
if (itemListAdapter == null) return;
|
||||||
|
|
||||||
itemListAdapter.removeItem(item);
|
itemListAdapter.removeItem(item);
|
||||||
if (playlistManager.getPlaylistThumbnail(playlistId).equals(item.thumbnailUrl))
|
if (playlistManager.getPlaylistThumbnail(playlistId).equals(item.getStreamEntity().getThumbnailUrl()))
|
||||||
updateThumbnailUrl();
|
updateThumbnailUrl();
|
||||||
|
|
||||||
setVideoCount(itemListAdapter.getItemsList().size());
|
setVideoCount(itemListAdapter.getItemsList().size());
|
||||||
|
@ -472,7 +472,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||||
List<Long> streamIds = new ArrayList<>(items.size());
|
List<Long> streamIds = new ArrayList<>(items.size());
|
||||||
for (final LocalItem item : items) {
|
for (final LocalItem item : items) {
|
||||||
if (item instanceof PlaylistStreamEntry) {
|
if (item instanceof PlaylistStreamEntry) {
|
||||||
streamIds.add(((PlaylistStreamEntry) item).streamId);
|
streamIds.add(((PlaylistStreamEntry) item).getStreamId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -579,7 +579,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||||
StreamDialogEntry.start_here_on_background.setCustomAction(
|
StreamDialogEntry.start_here_on_background.setCustomAction(
|
||||||
(fragment, infoItemDuplicate) -> NavigationHelper.playOnBackgroundPlayer(context, getPlayQueueStartingAt(item), true));
|
(fragment, infoItemDuplicate) -> NavigationHelper.playOnBackgroundPlayer(context, getPlayQueueStartingAt(item), true));
|
||||||
StreamDialogEntry.set_as_playlist_thumbnail.setCustomAction(
|
StreamDialogEntry.set_as_playlist_thumbnail.setCustomAction(
|
||||||
(fragment, infoItemDuplicate) -> changeThumbnailUrl(item.thumbnailUrl));
|
(fragment, infoItemDuplicate) -> changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl()));
|
||||||
StreamDialogEntry.delete.setCustomAction(
|
StreamDialogEntry.delete.setCustomAction(
|
||||||
(fragment, infoItemDuplicate) -> deleteItem(item));
|
(fragment, infoItemDuplicate) -> deleteItem(item));
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
package org.schabi.newpipe.local.subscription
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.util.ThemeHelper
|
||||||
|
|
||||||
|
enum class FeedGroupIcon(
|
||||||
|
/**
|
||||||
|
* The id that will be used to store and retrieve icons from some persistent storage (e.g. DB).
|
||||||
|
*/
|
||||||
|
val id: Int,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attribute that points to a drawable resource. "R.attr" is used here to support multiple themes.
|
||||||
|
*/
|
||||||
|
@AttrRes val drawableResourceAttr: Int
|
||||||
|
) {
|
||||||
|
ALL(0, R.attr.ic_asterisk),
|
||||||
|
MUSIC(1, R.attr.ic_music_note),
|
||||||
|
EDUCATION(2, R.attr.ic_school),
|
||||||
|
FITNESS(3, R.attr.ic_fitness),
|
||||||
|
SPACE(4, R.attr.ic_telescope),
|
||||||
|
COMPUTER(5, R.attr.ic_computer),
|
||||||
|
GAMING(6, R.attr.ic_videogame),
|
||||||
|
SPORTS(7, R.attr.ic_sports),
|
||||||
|
NEWS(8, R.attr.ic_megaphone),
|
||||||
|
FAVORITES(9, R.attr.ic_heart),
|
||||||
|
CAR(10, R.attr.ic_car),
|
||||||
|
MOTORCYCLE(11, R.attr.ic_motorcycle),
|
||||||
|
TREND(12, R.attr.ic_trending_up),
|
||||||
|
MOVIE(13, R.attr.ic_movie),
|
||||||
|
BACKUP(14, R.attr.ic_backup),
|
||||||
|
ART(15, R.attr.palette),
|
||||||
|
PERSON(16, R.attr.ic_person),
|
||||||
|
PEOPLE(17, R.attr.ic_people),
|
||||||
|
MONEY(18, R.attr.ic_money),
|
||||||
|
KIDS(19, R.attr.ic_kids),
|
||||||
|
FOOD(20, R.attr.ic_fastfood),
|
||||||
|
SMILE(21, R.attr.ic_smile),
|
||||||
|
EXPLORE(22, R.attr.ic_explore),
|
||||||
|
RESTAURANT(23, R.attr.ic_restaurant),
|
||||||
|
MIC(24, R.attr.ic_mic),
|
||||||
|
HEADSET(25, R.attr.audio),
|
||||||
|
RADIO(26, R.attr.ic_radio),
|
||||||
|
SHOPPING_CART(27, R.attr.ic_shopping_cart),
|
||||||
|
WATCH_LATER(28, R.attr.ic_watch_later),
|
||||||
|
WORK(29, R.attr.ic_work),
|
||||||
|
HOT(30, R.attr.ic_hot),
|
||||||
|
CHANNEL(31, R.attr.ic_channel),
|
||||||
|
BOOKMARK(32, R.attr.ic_bookmark),
|
||||||
|
PETS(33, R.attr.ic_pets),
|
||||||
|
WORLD(34, R.attr.ic_world),
|
||||||
|
STAR(35, R.attr.ic_stars),
|
||||||
|
SUN(36, R.attr.ic_sunny),
|
||||||
|
RSS(37, R.attr.rss);
|
||||||
|
|
||||||
|
@DrawableRes
|
||||||
|
fun getDrawableRes(context: Context): Int {
|
||||||
|
return ThemeHelper.resolveResourceIdFromAttr(context, drawableResourceAttr)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,595 +0,0 @@
|
||||||
package org.schabi.newpipe.local.subscription;
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
|
||||||
import android.app.Activity;
|
|
||||||
import android.app.AlertDialog;
|
|
||||||
import android.content.BroadcastReceiver;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.DialogInterface;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.IntentFilter;
|
|
||||||
import android.content.SharedPreferences;
|
|
||||||
import android.content.res.Configuration;
|
|
||||||
import android.content.res.Resources;
|
|
||||||
import android.graphics.Color;
|
|
||||||
import android.graphics.PorterDuff;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.os.Environment;
|
|
||||||
import android.os.Parcelable;
|
|
||||||
import android.preference.PreferenceManager;
|
|
||||||
import androidx.annotation.DrawableRes;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.fragment.app.FragmentManager;
|
|
||||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
|
||||||
import androidx.appcompat.app.ActionBar;
|
|
||||||
import androidx.recyclerview.widget.GridLayoutManager;
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.Menu;
|
|
||||||
import android.view.MenuInflater;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.ViewGroup;
|
|
||||||
import android.widget.ImageView;
|
|
||||||
import android.widget.TextView;
|
|
||||||
import android.widget.Toast;
|
|
||||||
|
|
||||||
import com.nononsenseapps.filepicker.Utils;
|
|
||||||
|
|
||||||
import org.schabi.newpipe.R;
|
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
|
||||||
import org.schabi.newpipe.extractor.InfoItem;
|
|
||||||
import org.schabi.newpipe.extractor.NewPipe;
|
|
||||||
import org.schabi.newpipe.extractor.StreamingService;
|
|
||||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
|
||||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
|
|
||||||
import org.schabi.newpipe.fragments.BaseStateFragment;
|
|
||||||
import org.schabi.newpipe.info_list.InfoListAdapter;
|
|
||||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService;
|
|
||||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService;
|
|
||||||
import org.schabi.newpipe.report.UserAction;
|
|
||||||
import org.schabi.newpipe.util.FilePickerActivityHelper;
|
|
||||||
import org.schabi.newpipe.util.NavigationHelper;
|
|
||||||
import org.schabi.newpipe.util.OnClickGesture;
|
|
||||||
import org.schabi.newpipe.util.ServiceHelper;
|
|
||||||
import org.schabi.newpipe.util.ShareUtils;
|
|
||||||
import org.schabi.newpipe.util.ThemeHelper;
|
|
||||||
import org.schabi.newpipe.views.CollapsibleView;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.text.SimpleDateFormat;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.Date;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
import icepick.State;
|
|
||||||
import io.reactivex.Observer;
|
|
||||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
|
||||||
import io.reactivex.disposables.CompositeDisposable;
|
|
||||||
import io.reactivex.disposables.Disposable;
|
|
||||||
import io.reactivex.schedulers.Schedulers;
|
|
||||||
|
|
||||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE;
|
|
||||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE;
|
|
||||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE;
|
|
||||||
import static org.schabi.newpipe.util.AnimationUtils.animateRotation;
|
|
||||||
import static org.schabi.newpipe.util.AnimationUtils.animateView;
|
|
||||||
|
|
||||||
public class SubscriptionFragment extends BaseStateFragment<List<SubscriptionEntity>> implements SharedPreferences.OnSharedPreferenceChangeListener {
|
|
||||||
private static final int REQUEST_EXPORT_CODE = 666;
|
|
||||||
private static final int REQUEST_IMPORT_CODE = 667;
|
|
||||||
|
|
||||||
private RecyclerView itemsList;
|
|
||||||
@State
|
|
||||||
protected Parcelable itemsListState;
|
|
||||||
private InfoListAdapter infoListAdapter;
|
|
||||||
private int updateFlags = 0;
|
|
||||||
|
|
||||||
private static final int LIST_MODE_UPDATE_FLAG = 0x32;
|
|
||||||
|
|
||||||
private View whatsNewItemListHeader;
|
|
||||||
private View importExportListHeader;
|
|
||||||
|
|
||||||
@State
|
|
||||||
protected Parcelable importExportOptionsState;
|
|
||||||
private CollapsibleView importExportOptions;
|
|
||||||
|
|
||||||
private CompositeDisposable disposables = new CompositeDisposable();
|
|
||||||
private SubscriptionService subscriptionService;
|
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////
|
|
||||||
// Fragment LifeCycle
|
|
||||||
///////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreate(Bundle savedInstanceState) {
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
setHasOptionsMenu(true);
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(activity)
|
|
||||||
.registerOnSharedPreferenceChangeListener(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setUserVisibleHint(boolean isVisibleToUser) {
|
|
||||||
super.setUserVisibleHint(isVisibleToUser);
|
|
||||||
if (activity != null && isVisibleToUser) {
|
|
||||||
setTitle(activity.getString(R.string.tab_subscriptions));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onAttach(Context context) {
|
|
||||||
super.onAttach(context);
|
|
||||||
infoListAdapter = new InfoListAdapter(activity);
|
|
||||||
subscriptionService = SubscriptionService.getInstance(activity);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDetach() {
|
|
||||||
super.onDetach();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
@Override
|
|
||||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) {
|
|
||||||
return inflater.inflate(R.layout.fragment_subscription, container, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onResume() {
|
|
||||||
super.onResume();
|
|
||||||
setupBroadcastReceiver();
|
|
||||||
if (updateFlags != 0) {
|
|
||||||
if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) {
|
|
||||||
final boolean useGrid = isGridLayout();
|
|
||||||
itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
|
|
||||||
infoListAdapter.setGridItemVariants(useGrid);
|
|
||||||
infoListAdapter.notifyDataSetChanged();
|
|
||||||
}
|
|
||||||
updateFlags = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onPause() {
|
|
||||||
super.onPause();
|
|
||||||
itemsListState = itemsList.getLayoutManager().onSaveInstanceState();
|
|
||||||
importExportOptionsState = importExportOptions.onSaveInstanceState();
|
|
||||||
|
|
||||||
if (subscriptionBroadcastReceiver != null && activity != null) {
|
|
||||||
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDestroyView() {
|
|
||||||
if (disposables != null) disposables.clear();
|
|
||||||
|
|
||||||
super.onDestroyView();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDestroy() {
|
|
||||||
if (disposables != null) disposables.dispose();
|
|
||||||
disposables = null;
|
|
||||||
subscriptionService = null;
|
|
||||||
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(activity)
|
|
||||||
.unregisterOnSharedPreferenceChangeListener(this);
|
|
||||||
super.onDestroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected RecyclerView.LayoutManager getListLayoutManager() {
|
|
||||||
return new LinearLayoutManager(activity);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected RecyclerView.LayoutManager getGridLayoutManager() {
|
|
||||||
final Resources resources = activity.getResources();
|
|
||||||
int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width);
|
|
||||||
width += (24 * resources.getDisplayMetrics().density);
|
|
||||||
final int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels / (double)width);
|
|
||||||
final GridLayoutManager lm = new GridLayoutManager(activity, spanCount);
|
|
||||||
lm.setSpanSizeLookup(infoListAdapter.getSpanSizeLookup(spanCount));
|
|
||||||
return lm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*/////////////////////////////////////////////////////////////////////////
|
|
||||||
// Menu
|
|
||||||
/////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
|
|
||||||
super.onCreateOptionsMenu(menu, inflater);
|
|
||||||
|
|
||||||
ActionBar supportActionBar = activity.getSupportActionBar();
|
|
||||||
if (supportActionBar != null) {
|
|
||||||
supportActionBar.setDisplayShowTitleEnabled(true);
|
|
||||||
setTitle(getString(R.string.tab_subscriptions));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Subscriptions import/export
|
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
private BroadcastReceiver subscriptionBroadcastReceiver;
|
|
||||||
|
|
||||||
private void setupBroadcastReceiver() {
|
|
||||||
if (activity == null) return;
|
|
||||||
|
|
||||||
if (subscriptionBroadcastReceiver != null) {
|
|
||||||
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver);
|
|
||||||
}
|
|
||||||
|
|
||||||
final IntentFilter filters = new IntentFilter();
|
|
||||||
filters.addAction(SubscriptionsExportService.EXPORT_COMPLETE_ACTION);
|
|
||||||
filters.addAction(SubscriptionsImportService.IMPORT_COMPLETE_ACTION);
|
|
||||||
subscriptionBroadcastReceiver = new BroadcastReceiver() {
|
|
||||||
@Override
|
|
||||||
public void onReceive(Context context, Intent intent) {
|
|
||||||
if (importExportOptions != null) importExportOptions.collapse();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver, filters);
|
|
||||||
}
|
|
||||||
|
|
||||||
private View addItemView(final String title, @DrawableRes final int icon, ViewGroup container) {
|
|
||||||
final View itemRoot = View.inflate(getContext(), R.layout.subscription_import_export_item, null);
|
|
||||||
final TextView titleView = itemRoot.findViewById(android.R.id.text1);
|
|
||||||
final ImageView iconView = itemRoot.findViewById(android.R.id.icon1);
|
|
||||||
|
|
||||||
titleView.setText(title);
|
|
||||||
iconView.setImageResource(icon);
|
|
||||||
|
|
||||||
container.addView(itemRoot);
|
|
||||||
return itemRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setupImportFromItems(final ViewGroup listHolder) {
|
|
||||||
final View previousBackupItem = addItemView(getString(R.string.previous_export),
|
|
||||||
ThemeHelper.resolveResourceIdFromAttr(getContext(), R.attr.ic_backup), listHolder);
|
|
||||||
previousBackupItem.setOnClickListener(item -> onImportPreviousSelected());
|
|
||||||
|
|
||||||
final int iconColor = ThemeHelper.isLightThemeSelected(getContext()) ? Color.BLACK : Color.WHITE;
|
|
||||||
final String[] services = getResources().getStringArray(R.array.service_list);
|
|
||||||
for (String serviceName : services) {
|
|
||||||
try {
|
|
||||||
final StreamingService service = NewPipe.getService(serviceName);
|
|
||||||
|
|
||||||
final SubscriptionExtractor subscriptionExtractor = service.getSubscriptionExtractor();
|
|
||||||
if (subscriptionExtractor == null) continue;
|
|
||||||
|
|
||||||
final List<SubscriptionExtractor.ContentSource> supportedSources = subscriptionExtractor.getSupportedSources();
|
|
||||||
if (supportedSources.isEmpty()) continue;
|
|
||||||
|
|
||||||
final View itemView = addItemView(serviceName, ServiceHelper.getIcon(service.getServiceId()), listHolder);
|
|
||||||
final ImageView iconView = itemView.findViewById(android.R.id.icon1);
|
|
||||||
iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN);
|
|
||||||
|
|
||||||
itemView.setOnClickListener(selectedItem -> onImportFromServiceSelected(service.getServiceId()));
|
|
||||||
} catch (ExtractionException e) {
|
|
||||||
throw new RuntimeException("Services array contains an entry that it's not a valid service name (" + serviceName + ")", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setupExportToItems(final ViewGroup listHolder) {
|
|
||||||
final View previousBackupItem = addItemView(getString(R.string.file), ThemeHelper.resolveResourceIdFromAttr(getContext(), R.attr.ic_save), listHolder);
|
|
||||||
previousBackupItem.setOnClickListener(item -> onExportSelected());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onImportFromServiceSelected(int serviceId) {
|
|
||||||
FragmentManager fragmentManager = getFM();
|
|
||||||
NavigationHelper.openSubscriptionsImportFragment(fragmentManager, serviceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onImportPreviousSelected() {
|
|
||||||
startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void onExportSelected() {
|
|
||||||
final String date = new SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(new Date());
|
|
||||||
final String exportName = "newpipe_subscriptions_" + date + ".json";
|
|
||||||
final File exportFile = new File(Environment.getExternalStorageDirectory(), exportName);
|
|
||||||
|
|
||||||
startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.getAbsolutePath()), REQUEST_EXPORT_CODE);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
|
||||||
super.onActivityResult(requestCode, resultCode, data);
|
|
||||||
if (data != null && data.getData() != null && resultCode == Activity.RESULT_OK) {
|
|
||||||
if (requestCode == REQUEST_EXPORT_CODE) {
|
|
||||||
final File exportFile = Utils.getFileForUri(data.getData());
|
|
||||||
if (!exportFile.getParentFile().canWrite() || !exportFile.getParentFile().canRead()) {
|
|
||||||
Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show();
|
|
||||||
} else {
|
|
||||||
activity.startService(new Intent(activity, SubscriptionsExportService.class)
|
|
||||||
.putExtra(SubscriptionsExportService.KEY_FILE_PATH, exportFile.getAbsolutePath()));
|
|
||||||
}
|
|
||||||
} else if (requestCode == REQUEST_IMPORT_CODE) {
|
|
||||||
final String path = Utils.getFileForUri(data.getData()).getAbsolutePath();
|
|
||||||
ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class)
|
|
||||||
.putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE)
|
|
||||||
.putExtra(KEY_VALUE, path));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/*/////////////////////////////////////////////////////////////////////////
|
|
||||||
// Fragment Views
|
|
||||||
/////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void initViews(View rootView, Bundle savedInstanceState) {
|
|
||||||
super.initViews(rootView, savedInstanceState);
|
|
||||||
|
|
||||||
final boolean useGrid = isGridLayout();
|
|
||||||
infoListAdapter = new InfoListAdapter(getActivity());
|
|
||||||
itemsList = rootView.findViewById(R.id.items_list);
|
|
||||||
itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
|
|
||||||
|
|
||||||
View headerRootLayout;
|
|
||||||
infoListAdapter.setHeader(headerRootLayout = activity.getLayoutInflater().inflate(R.layout.subscription_header, itemsList, false));
|
|
||||||
whatsNewItemListHeader = headerRootLayout.findViewById(R.id.whats_new);
|
|
||||||
importExportListHeader = headerRootLayout.findViewById(R.id.import_export);
|
|
||||||
importExportOptions = headerRootLayout.findViewById(R.id.import_export_options);
|
|
||||||
|
|
||||||
infoListAdapter.useMiniItemVariants(true);
|
|
||||||
infoListAdapter.setGridItemVariants(useGrid);
|
|
||||||
itemsList.setAdapter(infoListAdapter);
|
|
||||||
|
|
||||||
setupImportFromItems(headerRootLayout.findViewById(R.id.import_from_options));
|
|
||||||
setupExportToItems(headerRootLayout.findViewById(R.id.export_to_options));
|
|
||||||
|
|
||||||
if (importExportOptionsState != null) {
|
|
||||||
importExportOptions.onRestoreInstanceState(importExportOptionsState);
|
|
||||||
importExportOptionsState = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
importExportOptions.addListener(getExpandIconSyncListener(headerRootLayout.findViewById(R.id.import_export_expand_icon)));
|
|
||||||
importExportOptions.ready();
|
|
||||||
}
|
|
||||||
|
|
||||||
private CollapsibleView.StateListener getExpandIconSyncListener(final ImageView iconView) {
|
|
||||||
return newState -> animateRotation(iconView, 250, newState == CollapsibleView.COLLAPSED ? 0 : 180);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void initListeners() {
|
|
||||||
super.initListeners();
|
|
||||||
|
|
||||||
infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<ChannelInfoItem>() {
|
|
||||||
|
|
||||||
public void selected(ChannelInfoItem selectedItem) {
|
|
||||||
final FragmentManager fragmentManager = getFM();
|
|
||||||
NavigationHelper.openChannelFragment(fragmentManager,
|
|
||||||
selectedItem.getServiceId(),
|
|
||||||
selectedItem.getUrl(),
|
|
||||||
selectedItem.getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void held(ChannelInfoItem selectedItem) {
|
|
||||||
showLongTapDialog(selectedItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
whatsNewItemListHeader.setOnClickListener(v -> {
|
|
||||||
FragmentManager fragmentManager = getFM();
|
|
||||||
NavigationHelper.openWhatsNewFragment(fragmentManager);
|
|
||||||
});
|
|
||||||
importExportListHeader.setOnClickListener(v -> importExportOptions.switchState());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void showLongTapDialog(ChannelInfoItem selectedItem) {
|
|
||||||
final Context context = getContext();
|
|
||||||
final Activity activity = getActivity();
|
|
||||||
if (context == null || context.getResources() == null || getActivity() == null) return;
|
|
||||||
|
|
||||||
final String[] commands = new String[]{
|
|
||||||
context.getResources().getString(R.string.unsubscribe),
|
|
||||||
context.getResources().getString(R.string.share)
|
|
||||||
};
|
|
||||||
|
|
||||||
final DialogInterface.OnClickListener actions = (dialogInterface, i) -> {
|
|
||||||
switch (i) {
|
|
||||||
case 0:
|
|
||||||
deleteChannel(selectedItem);
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
shareChannel(selectedItem);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
final View bannerView = View.inflate(activity, R.layout.dialog_title, null);
|
|
||||||
bannerView.setSelected(true);
|
|
||||||
|
|
||||||
TextView titleView = bannerView.findViewById(R.id.itemTitleView);
|
|
||||||
titleView.setText(selectedItem.getName());
|
|
||||||
|
|
||||||
TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails);
|
|
||||||
detailsView.setVisibility(View.GONE);
|
|
||||||
|
|
||||||
new AlertDialog.Builder(activity)
|
|
||||||
.setCustomTitle(bannerView)
|
|
||||||
.setItems(commands, actions)
|
|
||||||
.create()
|
|
||||||
.show();
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private void shareChannel(ChannelInfoItem selectedItem) {
|
|
||||||
ShareUtils.shareUrl(getContext(), selectedItem.getName(), selectedItem.getUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("CheckResult")
|
|
||||||
private void deleteChannel(ChannelInfoItem selectedItem) {
|
|
||||||
subscriptionService.subscriptionTable()
|
|
||||||
.getSubscription(selectedItem.getServiceId(), selectedItem.getUrl())
|
|
||||||
.toObservable()
|
|
||||||
.observeOn(Schedulers.io())
|
|
||||||
.subscribe(getDeleteObserver());
|
|
||||||
|
|
||||||
Toast.makeText(activity, getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private Observer<List<SubscriptionEntity>> getDeleteObserver() {
|
|
||||||
return new Observer<List<SubscriptionEntity>>() {
|
|
||||||
@Override
|
|
||||||
public void onSubscribe(Disposable d) {
|
|
||||||
disposables.add(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(List<SubscriptionEntity> subscriptionEntities) {
|
|
||||||
subscriptionService.subscriptionTable().delete(subscriptionEntities);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable exception) {
|
|
||||||
SubscriptionFragment.this.onError(exception);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() { }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private void resetFragment() {
|
|
||||||
if (disposables != null) disposables.clear();
|
|
||||||
if (infoListAdapter != null) infoListAdapter.clearStreamItemList();
|
|
||||||
}
|
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////
|
|
||||||
// Subscriptions Loader
|
|
||||||
///////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void startLoading(boolean forceLoad) {
|
|
||||||
super.startLoading(forceLoad);
|
|
||||||
resetFragment();
|
|
||||||
|
|
||||||
subscriptionService.getSubscription().toObservable()
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(getSubscriptionObserver());
|
|
||||||
}
|
|
||||||
|
|
||||||
private Observer<List<SubscriptionEntity>> getSubscriptionObserver() {
|
|
||||||
return new Observer<List<SubscriptionEntity>>() {
|
|
||||||
@Override
|
|
||||||
public void onSubscribe(Disposable d) {
|
|
||||||
showLoading();
|
|
||||||
disposables.add(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onNext(List<SubscriptionEntity> subscriptions) {
|
|
||||||
handleResult(subscriptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable exception) {
|
|
||||||
SubscriptionFragment.this.onError(exception);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void handleResult(@NonNull List<SubscriptionEntity> result) {
|
|
||||||
super.handleResult(result);
|
|
||||||
|
|
||||||
infoListAdapter.clearStreamItemList();
|
|
||||||
|
|
||||||
if (result.isEmpty()) {
|
|
||||||
whatsNewItemListHeader.setVisibility(View.GONE);
|
|
||||||
showEmptyState();
|
|
||||||
} else {
|
|
||||||
infoListAdapter.addInfoItemList(getSubscriptionItems(result));
|
|
||||||
if (itemsListState != null) {
|
|
||||||
itemsList.getLayoutManager().onRestoreInstanceState(itemsListState);
|
|
||||||
itemsListState = null;
|
|
||||||
}
|
|
||||||
whatsNewItemListHeader.setVisibility(View.VISIBLE);
|
|
||||||
hideLoading();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private List<InfoItem> getSubscriptionItems(List<SubscriptionEntity> subscriptions) {
|
|
||||||
List<InfoItem> items = new ArrayList<>();
|
|
||||||
for (final SubscriptionEntity subscription : subscriptions) {
|
|
||||||
items.add(subscription.toChannelInfoItem());
|
|
||||||
}
|
|
||||||
|
|
||||||
Collections.sort(items,
|
|
||||||
(InfoItem o1, InfoItem o2) ->
|
|
||||||
o1.getName().compareToIgnoreCase(o2.getName()));
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Contract
|
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void showLoading() {
|
|
||||||
super.showLoading();
|
|
||||||
animateView(itemsList, false, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void hideLoading() {
|
|
||||||
super.hideLoading();
|
|
||||||
animateView(itemsList, true, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////
|
|
||||||
// Fragment Error Handling
|
|
||||||
///////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected boolean onError(Throwable exception) {
|
|
||||||
resetFragment();
|
|
||||||
if (super.onError(exception)) return true;
|
|
||||||
|
|
||||||
onUnrecoverableError(exception,
|
|
||||||
UserAction.SOMETHING_ELSE,
|
|
||||||
"none",
|
|
||||||
"Subscriptions",
|
|
||||||
R.string.general_error);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
|
|
||||||
if (key.equals(getString(R.string.list_view_mode_key))) {
|
|
||||||
updateFlags |= LIST_MODE_UPDATE_FLAG;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected boolean isGridLayout() {
|
|
||||||
final String list_mode = PreferenceManager.getDefaultSharedPreferences(activity).getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value));
|
|
||||||
if ("auto".equals(list_mode)) {
|
|
||||||
final Configuration configuration = getResources().getConfiguration();
|
|
||||||
return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
|
||||||
&& configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE);
|
|
||||||
} else {
|
|
||||||
return "grid".equals(list_mode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,421 @@
|
||||||
|
package org.schabi.newpipe.local.subscription
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.content.*
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Environment
|
||||||
|
import android.os.Parcelable
|
||||||
|
import android.preference.PreferenceManager
|
||||||
|
import android.view.*
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.lifecycle.ViewModelProviders
|
||||||
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import com.nononsenseapps.filepicker.Utils
|
||||||
|
import com.xwray.groupie.Group
|
||||||
|
import com.xwray.groupie.GroupAdapter
|
||||||
|
import com.xwray.groupie.Item
|
||||||
|
import com.xwray.groupie.Section
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||||
|
import icepick.State
|
||||||
|
import io.reactivex.disposables.CompositeDisposable
|
||||||
|
import kotlinx.android.synthetic.main.dialog_title.view.*
|
||||||
|
import kotlinx.android.synthetic.main.fragment_subscription.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
|
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
|
||||||
|
import org.schabi.newpipe.fragments.BaseStateFragment
|
||||||
|
import org.schabi.newpipe.local.subscription.SubscriptionViewModel.SubscriptionState
|
||||||
|
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog
|
||||||
|
import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialog
|
||||||
|
import org.schabi.newpipe.local.subscription.item.*
|
||||||
|
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService
|
||||||
|
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE_ACTION
|
||||||
|
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.KEY_FILE_PATH
|
||||||
|
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService
|
||||||
|
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.*
|
||||||
|
import org.schabi.newpipe.report.UserAction
|
||||||
|
import org.schabi.newpipe.util.*
|
||||||
|
import org.schabi.newpipe.util.AnimationUtils.animateView
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||||
|
private lateinit var viewModel: SubscriptionViewModel
|
||||||
|
private lateinit var subscriptionManager: SubscriptionManager
|
||||||
|
private val disposables: CompositeDisposable = CompositeDisposable()
|
||||||
|
|
||||||
|
private var subscriptionBroadcastReceiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
|
private val groupAdapter = GroupAdapter<GroupieViewHolder>()
|
||||||
|
private val feedGroupsSection = Section()
|
||||||
|
private var feedGroupsCarousel: FeedGroupCarouselItem? = null
|
||||||
|
private lateinit var importExportItem: FeedImportExportItem
|
||||||
|
private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem
|
||||||
|
private val subscriptionsSection = Section()
|
||||||
|
|
||||||
|
@State @JvmField var itemsListState: Parcelable? = null
|
||||||
|
@State @JvmField var feedGroupsListState: Parcelable? = null
|
||||||
|
@State @JvmField var importExportItemExpandedState: Boolean? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
setHasOptionsMenu(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Fragment LifeCycle
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setupInitialLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setUserVisibleHint(isVisibleToUser: Boolean) {
|
||||||
|
super.setUserVisibleHint(isVisibleToUser)
|
||||||
|
if (activity != null && isVisibleToUser) {
|
||||||
|
setTitle(activity.getString(R.string.tab_subscriptions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
super.onAttach(context)
|
||||||
|
subscriptionManager = SubscriptionManager(requireContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||||
|
return inflater.inflate(R.layout.fragment_subscription, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
setupBroadcastReceiver()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
itemsListState = items_list.layoutManager?.onSaveInstanceState()
|
||||||
|
feedGroupsListState = feedGroupsCarousel?.onSaveInstanceState()
|
||||||
|
importExportItemExpandedState = importExportItem.isExpanded
|
||||||
|
|
||||||
|
if (subscriptionBroadcastReceiver != null && activity != null) {
|
||||||
|
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
disposables.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Menu
|
||||||
|
//////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
|
super.onCreateOptionsMenu(menu, inflater)
|
||||||
|
|
||||||
|
val supportActionBar = activity.supportActionBar
|
||||||
|
if (supportActionBar != null) {
|
||||||
|
supportActionBar.setDisplayShowTitleEnabled(true)
|
||||||
|
setTitle(getString(R.string.tab_subscriptions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupBroadcastReceiver() {
|
||||||
|
if (activity == null) return
|
||||||
|
|
||||||
|
if (subscriptionBroadcastReceiver != null) {
|
||||||
|
LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
val filters = IntentFilter()
|
||||||
|
filters.addAction(EXPORT_COMPLETE_ACTION)
|
||||||
|
filters.addAction(IMPORT_COMPLETE_ACTION)
|
||||||
|
subscriptionBroadcastReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
items_list?.post {
|
||||||
|
importExportItem.isExpanded = false
|
||||||
|
importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver!!, filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onImportFromServiceSelected(serviceId: Int) {
|
||||||
|
val fragmentManager = fm
|
||||||
|
NavigationHelper.openSubscriptionsImportFragment(fragmentManager, serviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onImportPreviousSelected() {
|
||||||
|
startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onExportSelected() {
|
||||||
|
val date = SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(Date())
|
||||||
|
val exportName = "newpipe_subscriptions_$date.json"
|
||||||
|
val exportFile = File(Environment.getExternalStorageDirectory(), exportName)
|
||||||
|
|
||||||
|
startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.absolutePath), REQUEST_EXPORT_CODE)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openReorderDialog() {
|
||||||
|
FeedGroupReorderDialog().show(requireFragmentManager(), null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
|
if (data != null && data.data != null && resultCode == Activity.RESULT_OK) {
|
||||||
|
if (requestCode == REQUEST_EXPORT_CODE) {
|
||||||
|
val exportFile = Utils.getFileForUri(data.data!!)
|
||||||
|
if (!exportFile.parentFile.canWrite() || !exportFile.parentFile.canRead()) {
|
||||||
|
Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
activity.startService(Intent(activity, SubscriptionsExportService::class.java)
|
||||||
|
.putExtra(KEY_FILE_PATH, exportFile.absolutePath))
|
||||||
|
}
|
||||||
|
} else if (requestCode == REQUEST_IMPORT_CODE) {
|
||||||
|
val path = Utils.getFileForUri(data.data!!).absolutePath
|
||||||
|
ImportConfirmationDialog.show(this, Intent(activity, SubscriptionsImportService::class.java)
|
||||||
|
.putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE)
|
||||||
|
.putExtra(KEY_VALUE, path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Fragment Views
|
||||||
|
//////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
private fun setupInitialLayout() {
|
||||||
|
Section().apply {
|
||||||
|
val carouselAdapter = GroupAdapter<GroupieViewHolder>()
|
||||||
|
|
||||||
|
carouselAdapter.add(FeedGroupCardItem(-1, getString(R.string.all), FeedGroupIcon.RSS))
|
||||||
|
carouselAdapter.add(feedGroupsSection)
|
||||||
|
carouselAdapter.add(FeedGroupAddItem())
|
||||||
|
|
||||||
|
carouselAdapter.setOnItemClickListener { item, _ ->
|
||||||
|
listenerFeedGroups.selected(item)
|
||||||
|
}
|
||||||
|
carouselAdapter.setOnItemLongClickListener { item, _ ->
|
||||||
|
if (item is FeedGroupCardItem) {
|
||||||
|
if (item.groupId == FeedGroupEntity.GROUP_ALL_ID) {
|
||||||
|
return@setOnItemLongClickListener false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listenerFeedGroups.held(item)
|
||||||
|
return@setOnItemLongClickListener true
|
||||||
|
}
|
||||||
|
|
||||||
|
feedGroupsCarousel = FeedGroupCarouselItem(requireContext(), carouselAdapter)
|
||||||
|
feedGroupsSortMenuItem = HeaderWithMenuItem(
|
||||||
|
getString(R.string.feed_groups_header_title),
|
||||||
|
ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_sort),
|
||||||
|
menuItemOnClickListener = ::openReorderDialog
|
||||||
|
)
|
||||||
|
add(Section(feedGroupsSortMenuItem, listOf(feedGroupsCarousel)))
|
||||||
|
|
||||||
|
groupAdapter.add(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionsSection.setPlaceholder(EmptyPlaceholderItem())
|
||||||
|
subscriptionsSection.setHideWhenEmpty(true)
|
||||||
|
|
||||||
|
importExportItem = FeedImportExportItem(
|
||||||
|
{ onImportPreviousSelected() },
|
||||||
|
{ onImportFromServiceSelected(it) },
|
||||||
|
{ onExportSelected() },
|
||||||
|
importExportItemExpandedState ?: false)
|
||||||
|
groupAdapter.add(Section(importExportItem, listOf(subscriptionsSection)))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun initViews(rootView: View, savedInstanceState: Bundle?) {
|
||||||
|
super.initViews(rootView, savedInstanceState)
|
||||||
|
|
||||||
|
val shouldUseGridLayout = shouldUseGridLayout()
|
||||||
|
groupAdapter.spanCount = if (shouldUseGridLayout) getGridSpanCount() else 1
|
||||||
|
items_list.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply {
|
||||||
|
spanSizeLookup = groupAdapter.spanSizeLookup
|
||||||
|
}
|
||||||
|
items_list.adapter = groupAdapter
|
||||||
|
|
||||||
|
viewModel = ViewModelProviders.of(this).get(SubscriptionViewModel::class.java)
|
||||||
|
viewModel.stateLiveData.observe(viewLifecycleOwner, androidx.lifecycle.Observer { it?.let(this::handleResult) })
|
||||||
|
viewModel.feedGroupsLiveData.observe(viewLifecycleOwner, androidx.lifecycle.Observer { it?.let(this::handleFeedGroups) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showLongTapDialog(selectedItem: ChannelInfoItem) {
|
||||||
|
val commands = arrayOf(
|
||||||
|
getString(R.string.share),
|
||||||
|
getString(R.string.unsubscribe)
|
||||||
|
)
|
||||||
|
|
||||||
|
val actions = DialogInterface.OnClickListener { _, i ->
|
||||||
|
when (i) {
|
||||||
|
0 -> ShareUtils.shareUrl(requireContext(), selectedItem.name, selectedItem.url)
|
||||||
|
1 -> deleteChannel(selectedItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val bannerView = View.inflate(requireContext(), R.layout.dialog_title, null)
|
||||||
|
bannerView.isSelected = true
|
||||||
|
bannerView.itemTitleView.text = selectedItem.name
|
||||||
|
bannerView.itemAdditionalDetails.visibility = View.GONE
|
||||||
|
|
||||||
|
AlertDialog.Builder(requireContext())
|
||||||
|
.setCustomTitle(bannerView)
|
||||||
|
.setItems(commands, actions)
|
||||||
|
.create()
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteChannel(selectedItem: ChannelInfoItem) {
|
||||||
|
disposables.add(subscriptionManager.deleteSubscription(selectedItem.serviceId, selectedItem.url).subscribe {
|
||||||
|
Toast.makeText(requireContext(), getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun doInitialLoadLogic() = Unit
|
||||||
|
override fun startLoading(forceLoad: Boolean) = Unit
|
||||||
|
|
||||||
|
private val listenerFeedGroups = object : OnClickGesture<Item<*>>() {
|
||||||
|
override fun selected(selectedItem: Item<*>?) {
|
||||||
|
when (selectedItem) {
|
||||||
|
is FeedGroupCardItem -> NavigationHelper.openFeedFragment(fm, selectedItem.groupId, selectedItem.name)
|
||||||
|
is FeedGroupAddItem -> FeedGroupDialog.newInstance().show(fm, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun held(selectedItem: Item<*>?) {
|
||||||
|
when (selectedItem) {
|
||||||
|
is FeedGroupCardItem -> FeedGroupDialog.newInstance(selectedItem.groupId).show(fm, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val listenerChannelItem = object : OnClickGesture<ChannelInfoItem>() {
|
||||||
|
override fun selected(selectedItem: ChannelInfoItem) = NavigationHelper.openChannelFragment(fm,
|
||||||
|
selectedItem.serviceId, selectedItem.url, selectedItem.name)
|
||||||
|
|
||||||
|
override fun held(selectedItem: ChannelInfoItem) = showLongTapDialog(selectedItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun handleResult(result: SubscriptionState) {
|
||||||
|
super.handleResult(result)
|
||||||
|
|
||||||
|
val shouldUseGridLayout = shouldUseGridLayout()
|
||||||
|
when (result) {
|
||||||
|
is SubscriptionState.LoadedState -> {
|
||||||
|
result.subscriptions.forEach {
|
||||||
|
if (it is ChannelItem) {
|
||||||
|
it.gesturesListener = listenerChannelItem
|
||||||
|
it.itemVersion = when {
|
||||||
|
shouldUseGridLayout -> ChannelItem.ItemVersion.GRID
|
||||||
|
else -> ChannelItem.ItemVersion.MINI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionsSection.update(result.subscriptions)
|
||||||
|
subscriptionsSection.setHideWhenEmpty(false)
|
||||||
|
|
||||||
|
if (result.subscriptions.isEmpty() && importExportItemExpandedState == null) {
|
||||||
|
items_list.post {
|
||||||
|
importExportItem.isExpanded = true
|
||||||
|
importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemsListState != null) {
|
||||||
|
items_list.layoutManager?.onRestoreInstanceState(itemsListState)
|
||||||
|
itemsListState = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is SubscriptionState.ErrorState -> {
|
||||||
|
result.error?.let { onError(result.error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleFeedGroups(groups: List<Group>) {
|
||||||
|
feedGroupsSection.update(groups)
|
||||||
|
|
||||||
|
if (feedGroupsListState != null) {
|
||||||
|
feedGroupsCarousel?.onRestoreInstanceState(feedGroupsListState)
|
||||||
|
feedGroupsListState = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groups.size < 2) {
|
||||||
|
items_list.post { feedGroupsSortMenuItem.notifyChanged(HeaderWithMenuItem.PAYLOAD_HIDE_MENU_ITEM) }
|
||||||
|
} else {
|
||||||
|
items_list.post { feedGroupsSortMenuItem.notifyChanged(HeaderWithMenuItem.PAYLOAD_SHOW_MENU_ITEM) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Contract
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
override fun showLoading() {
|
||||||
|
super.showLoading()
|
||||||
|
animateView(items_list, false, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hideLoading() {
|
||||||
|
super.hideLoading()
|
||||||
|
animateView(items_list, true, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Fragment Error Handling
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
override fun onError(exception: Throwable): Boolean {
|
||||||
|
if (super.onError(exception)) return true
|
||||||
|
|
||||||
|
onUnrecoverableError(exception, UserAction.SOMETHING_ELSE, "none", "Subscriptions", R.string.general_error)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Grid Mode
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// TODO: Move these out of this class, as it can be reused
|
||||||
|
|
||||||
|
private fun shouldUseGridLayout(): Boolean {
|
||||||
|
val listMode = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||||
|
.getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value))
|
||||||
|
|
||||||
|
return when (listMode) {
|
||||||
|
getString(R.string.list_view_mode_auto_key) -> {
|
||||||
|
val configuration = resources.configuration
|
||||||
|
|
||||||
|
(configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
&& configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE))
|
||||||
|
}
|
||||||
|
getString(R.string.list_view_mode_grid_key) -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getGridSpanCount(): Int {
|
||||||
|
val minWidth = resources.getDimensionPixelSize(R.dimen.channel_item_grid_min_width)
|
||||||
|
return max(1, floor(resources.displayMetrics.widthPixels / minWidth.toDouble()).toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val REQUEST_EXPORT_CODE = 666
|
||||||
|
private const val REQUEST_IMPORT_CODE = 667
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,74 @@
|
||||||
|
package org.schabi.newpipe.local.subscription
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import io.reactivex.Completable
|
||||||
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import org.schabi.newpipe.NewPipeDatabase
|
||||||
|
import org.schabi.newpipe.database.subscription.SubscriptionDAO
|
||||||
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||||
|
import org.schabi.newpipe.extractor.ListInfo
|
||||||
|
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
|
||||||
|
|
||||||
|
class SubscriptionManager(context: Context) {
|
||||||
|
private val database = NewPipeDatabase.getInstance(context)
|
||||||
|
private val subscriptionTable = database.subscriptionDAO()
|
||||||
|
private val feedDatabaseManager = FeedDatabaseManager(context)
|
||||||
|
|
||||||
|
fun subscriptionTable(): SubscriptionDAO = subscriptionTable
|
||||||
|
fun subscriptions() = subscriptionTable.all
|
||||||
|
|
||||||
|
fun upsertAll(infoList: List<ChannelInfo>): List<SubscriptionEntity> {
|
||||||
|
val listEntities = subscriptionTable.upsertAll(
|
||||||
|
infoList.map { SubscriptionEntity.from(it) })
|
||||||
|
|
||||||
|
database.runInTransaction {
|
||||||
|
infoList.forEachIndexed { index, info ->
|
||||||
|
feedDatabaseManager.upsertAll(listEntities[index].uid, info.relatedItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return listEntities
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateChannelInfo(info: ChannelInfo): Completable = subscriptionTable.getSubscription(info.serviceId, info.url)
|
||||||
|
.flatMapCompletable {
|
||||||
|
Completable.fromRunnable {
|
||||||
|
it.setData(info.name, info.avatarUrl, info.description, info.subscriberCount)
|
||||||
|
subscriptionTable.update(it)
|
||||||
|
feedDatabaseManager.upsertAll(it.uid, info.relatedItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionTable.update(subscriptionEntity)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSubscription(serviceId: Int, url: String): Completable {
|
||||||
|
return Completable.fromCallable { subscriptionTable.deleteSubscription(serviceId, url) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun insertSubscription(subscriptionEntity: SubscriptionEntity, info: ChannelInfo) {
|
||||||
|
database.runInTransaction {
|
||||||
|
val subscriptionId = subscriptionTable.insert(subscriptionEntity)
|
||||||
|
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSubscription(subscriptionEntity: SubscriptionEntity) {
|
||||||
|
subscriptionTable.delete(subscriptionEntity)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,162 +0,0 @@
|
||||||
package org.schabi.newpipe.local.subscription;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
import org.schabi.newpipe.MainActivity;
|
|
||||||
import org.schabi.newpipe.NewPipeDatabase;
|
|
||||||
import org.schabi.newpipe.database.AppDatabase;
|
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionDAO;
|
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
|
||||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
|
||||||
import org.schabi.newpipe.util.ExtractorHelper;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.Executor;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
import io.reactivex.Completable;
|
|
||||||
import io.reactivex.CompletableSource;
|
|
||||||
import io.reactivex.Flowable;
|
|
||||||
import io.reactivex.Maybe;
|
|
||||||
import io.reactivex.Scheduler;
|
|
||||||
import io.reactivex.functions.Function;
|
|
||||||
import io.reactivex.schedulers.Schedulers;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscription Service singleton:
|
|
||||||
* Provides a basis for channel Subscriptions.
|
|
||||||
* Provides access to subscription table in database as well as
|
|
||||||
* up-to-date observations on the subscribed channels
|
|
||||||
*/
|
|
||||||
public class SubscriptionService {
|
|
||||||
|
|
||||||
private static volatile SubscriptionService instance;
|
|
||||||
|
|
||||||
public static SubscriptionService getInstance(@NonNull Context context) {
|
|
||||||
SubscriptionService result = instance;
|
|
||||||
if (result == null) {
|
|
||||||
synchronized (SubscriptionService.class) {
|
|
||||||
result = instance;
|
|
||||||
if (result == null) {
|
|
||||||
instance = (result = new SubscriptionService(context));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected final String TAG = "SubscriptionService@" + Integer.toHexString(hashCode());
|
|
||||||
protected static final boolean DEBUG = MainActivity.DEBUG;
|
|
||||||
private static final int SUBSCRIPTION_DEBOUNCE_INTERVAL = 500;
|
|
||||||
private static final int SUBSCRIPTION_THREAD_POOL_SIZE = 4;
|
|
||||||
|
|
||||||
private final AppDatabase db;
|
|
||||||
private final Flowable<List<SubscriptionEntity>> subscription;
|
|
||||||
|
|
||||||
private final Scheduler subscriptionScheduler;
|
|
||||||
|
|
||||||
private SubscriptionService(Context context) {
|
|
||||||
db = NewPipeDatabase.getInstance(context.getApplicationContext());
|
|
||||||
subscription = getSubscriptionInfos();
|
|
||||||
|
|
||||||
final Executor subscriptionExecutor = Executors.newFixedThreadPool(SUBSCRIPTION_THREAD_POOL_SIZE);
|
|
||||||
subscriptionScheduler = Schedulers.from(subscriptionExecutor);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Part of subscription observation pipeline
|
|
||||||
*
|
|
||||||
* @see SubscriptionService#getSubscription()
|
|
||||||
*/
|
|
||||||
private Flowable<List<SubscriptionEntity>> getSubscriptionInfos() {
|
|
||||||
return subscriptionTable().getAll()
|
|
||||||
// Wait for a period of infrequent updates and return the latest update
|
|
||||||
.debounce(SUBSCRIPTION_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS)
|
|
||||||
.share() // Share allows multiple subscribers on the same observable
|
|
||||||
.replay(1) // Replay synchronizes subscribers to the last emitted result
|
|
||||||
.autoConnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides an observer to the latest update to the subscription table.
|
|
||||||
* <p>
|
|
||||||
* This observer may be subscribed multiple times, where each subscriber obtains
|
|
||||||
* the latest synchronized changes available, effectively share the same data
|
|
||||||
* across all subscribers.
|
|
||||||
* <p>
|
|
||||||
* This observer has a debounce cooldown, meaning if multiple updates are observed
|
|
||||||
* in the cooldown interval, only the latest changes are emitted to the subscribers.
|
|
||||||
* This reduces the amount of observations caused by frequent updates to the database.
|
|
||||||
*/
|
|
||||||
@androidx.annotation.NonNull
|
|
||||||
public Flowable<List<SubscriptionEntity>> getSubscription() {
|
|
||||||
return subscription;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Maybe<ChannelInfo> getChannelInfo(final SubscriptionEntity subscriptionEntity) {
|
|
||||||
if (DEBUG) Log.d(TAG, "getChannelInfo() called with: subscriptionEntity = [" + subscriptionEntity + "]");
|
|
||||||
|
|
||||||
return Maybe.fromSingle(ExtractorHelper
|
|
||||||
.getChannelInfo(subscriptionEntity.getServiceId(), subscriptionEntity.getUrl(), false))
|
|
||||||
.subscribeOn(subscriptionScheduler);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the database access interface for subscription table.
|
|
||||||
*/
|
|
||||||
public SubscriptionDAO subscriptionTable() {
|
|
||||||
return db.subscriptionDAO();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Completable updateChannelInfo(final ChannelInfo info) {
|
|
||||||
final Function<List<SubscriptionEntity>, CompletableSource> update = new Function<List<SubscriptionEntity>, CompletableSource>() {
|
|
||||||
@Override
|
|
||||||
public CompletableSource apply(@NonNull List<SubscriptionEntity> subscriptionEntities) {
|
|
||||||
if (DEBUG) Log.d(TAG, "updateChannelInfo() called with: subscriptionEntities = [" + subscriptionEntities + "]");
|
|
||||||
if (subscriptionEntities.size() == 1) {
|
|
||||||
SubscriptionEntity subscription = subscriptionEntities.get(0);
|
|
||||||
|
|
||||||
// Subscriber count changes very often, making this check almost unnecessary.
|
|
||||||
// Consider removing it later.
|
|
||||||
if (!isSubscriptionUpToDate(info, subscription)) {
|
|
||||||
subscription.setData(info.getName(), info.getAvatarUrl(), info.getDescription(), info.getSubscriberCount());
|
|
||||||
|
|
||||||
return Completable.fromRunnable(() -> subscriptionTable().update(subscription));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Completable.complete();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return subscriptionTable().getSubscription(info.getServiceId(), info.getUrl())
|
|
||||||
.firstOrError()
|
|
||||||
.flatMapCompletable(update);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<SubscriptionEntity> upsertAll(final List<ChannelInfo> infoList) {
|
|
||||||
final List<SubscriptionEntity> entityList = new ArrayList<>();
|
|
||||||
for (ChannelInfo info : infoList) entityList.add(SubscriptionEntity.from(info));
|
|
||||||
|
|
||||||
return subscriptionTable().upsertAll(entityList);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isSubscriptionUpToDate(final ChannelInfo info, final SubscriptionEntity entity) {
|
|
||||||
return equalsAndNotNull(info.getUrl(), entity.getUrl()) &&
|
|
||||||
info.getServiceId() == entity.getServiceId() &&
|
|
||||||
info.getName().equals(entity.getName()) &&
|
|
||||||
equalsAndNotNull(info.getAvatarUrl(), entity.getAvatarUrl()) &&
|
|
||||||
equalsAndNotNull(info.getDescription(), entity.getDescription()) &&
|
|
||||||
info.getSubscriberCount() == entity.getSubscriberCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean equalsAndNotNull(final Object o1, final Object o2) {
|
|
||||||
return (o1 != null && o2 != null)
|
|
||||||
&& o1.equals(o2);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
package org.schabi.newpipe.local.subscription
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import com.xwray.groupie.Group
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||||
|
import org.schabi.newpipe.local.subscription.item.ChannelItem
|
||||||
|
import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem
|
||||||
|
import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class SubscriptionViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application)
|
||||||
|
private var subscriptionManager = SubscriptionManager(application)
|
||||||
|
|
||||||
|
private val mutableStateLiveData = MutableLiveData<SubscriptionState>()
|
||||||
|
private val mutableFeedGroupsLiveData = MutableLiveData<List<Group>>()
|
||||||
|
val stateLiveData: LiveData<SubscriptionState> = mutableStateLiveData
|
||||||
|
val feedGroupsLiveData: LiveData<List<Group>> = mutableFeedGroupsLiveData
|
||||||
|
|
||||||
|
private var feedGroupItemsDisposable = feedDatabaseManager.groups()
|
||||||
|
.throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
|
||||||
|
.map { it.map(::FeedGroupCardItem) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.subscribe(
|
||||||
|
{ mutableFeedGroupsLiveData.postValue(it) },
|
||||||
|
{ mutableStateLiveData.postValue(SubscriptionState.ErrorState(it)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
private var stateItemsDisposable = subscriptionManager.subscriptions()
|
||||||
|
.throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
|
||||||
|
.map { it.map { entity -> ChannelItem(entity.toChannelInfoItem(), entity.uid, ChannelItem.ItemVersion.MINI) } }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.subscribe(
|
||||||
|
{ mutableStateLiveData.postValue(SubscriptionState.LoadedState(it)) },
|
||||||
|
{ mutableStateLiveData.postValue(SubscriptionState.ErrorState(it)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
stateItemsDisposable.dispose()
|
||||||
|
feedGroupItemsDisposable.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class SubscriptionState {
|
||||||
|
data class LoadedState(val subscriptions: List<Group>) : SubscriptionState()
|
||||||
|
data class ErrorState(val error: Throwable? = null) : SubscriptionState()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.decoration
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.view.View
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
|
||||||
|
class FeedGroupCarouselDecoration(context: Context) : RecyclerView.ItemDecoration() {
|
||||||
|
|
||||||
|
private val marginStartEnd: Int
|
||||||
|
private val marginTopBottom: Int
|
||||||
|
private val marginBetweenItems: Int
|
||||||
|
|
||||||
|
init {
|
||||||
|
with(context.resources) {
|
||||||
|
marginStartEnd = getDimensionPixelOffset(R.dimen.feed_group_carousel_start_end_margin)
|
||||||
|
marginTopBottom = getDimensionPixelOffset(R.dimen.feed_group_carousel_top_bottom_margin)
|
||||||
|
marginBetweenItems = getDimensionPixelOffset(R.dimen.feed_group_carousel_between_items_margin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemOffsets(outRect: Rect, child: View, parent: RecyclerView, state: RecyclerView.State) {
|
||||||
|
val childAdapterPosition = parent.getChildAdapterPosition(child)
|
||||||
|
val childAdapterCount = parent.adapter?.itemCount ?: 0
|
||||||
|
|
||||||
|
outRect.set(marginBetweenItems, marginTopBottom, 0, marginTopBottom)
|
||||||
|
|
||||||
|
if (childAdapterPosition == 0) {
|
||||||
|
outRect.left = marginStartEnd
|
||||||
|
} else if (childAdapterPosition == childAdapterCount - 1) {
|
||||||
|
outRect.right = marginStartEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,356 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.dialog
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Parcelable
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.lifecycle.ViewModelProviders
|
||||||
|
import androidx.recyclerview.widget.GridLayoutManager
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.xwray.groupie.GroupAdapter
|
||||||
|
import com.xwray.groupie.Section
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||||
|
import icepick.Icepick
|
||||||
|
import icepick.State
|
||||||
|
import kotlinx.android.synthetic.main.dialog_feed_group_create.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||||
|
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||||
|
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.*
|
||||||
|
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.ProcessingEvent
|
||||||
|
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.SuccessEvent
|
||||||
|
import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem
|
||||||
|
import org.schabi.newpipe.local.subscription.item.PickerIconItem
|
||||||
|
import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem
|
||||||
|
import org.schabi.newpipe.util.ThemeHelper
|
||||||
|
import java.io.Serializable
|
||||||
|
|
||||||
|
class FeedGroupDialog : DialogFragment() {
|
||||||
|
private lateinit var viewModel: FeedGroupDialogViewModel
|
||||||
|
private var groupId: Long = NO_GROUP_SELECTED
|
||||||
|
private var groupIcon: FeedGroupIcon? = null
|
||||||
|
private var groupSortOrder: Long = -1
|
||||||
|
|
||||||
|
sealed class ScreenState : Serializable {
|
||||||
|
object InitialScreen : ScreenState()
|
||||||
|
object IconPickerScreen : ScreenState()
|
||||||
|
object SubscriptionsPickerScreen : ScreenState()
|
||||||
|
object DeleteScreen : ScreenState()
|
||||||
|
}
|
||||||
|
|
||||||
|
@State @JvmField var selectedIcon: FeedGroupIcon? = null
|
||||||
|
@State @JvmField var selectedSubscriptions: HashSet<Long> = HashSet()
|
||||||
|
@State @JvmField var currentScreen: ScreenState = InitialScreen
|
||||||
|
|
||||||
|
@State @JvmField var subscriptionsListState: Parcelable? = null
|
||||||
|
@State @JvmField var iconsListState: Parcelable? = null
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
Icepick.restoreInstanceState(this, savedInstanceState)
|
||||||
|
|
||||||
|
setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext()))
|
||||||
|
groupId = arguments?.getLong(KEY_GROUP_ID, NO_GROUP_SELECTED) ?: NO_GROUP_SELECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||||
|
return inflater.inflate(R.layout.dialog_feed_group_create, container)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
return object : Dialog(requireActivity(), theme) {
|
||||||
|
override fun onBackPressed() {
|
||||||
|
if (currentScreen !is InitialScreen) {
|
||||||
|
showScreen(InitialScreen)
|
||||||
|
} else {
|
||||||
|
super.onBackPressed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
|
||||||
|
iconsListState = icon_selector.layoutManager?.onSaveInstanceState()
|
||||||
|
subscriptionsListState = subscriptions_selector_list.layoutManager?.onSaveInstanceState()
|
||||||
|
|
||||||
|
Icepick.saveInstanceState(this, outState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
viewModel = ViewModelProviders.of(this, FeedGroupDialogViewModel.Factory(requireContext(), groupId))
|
||||||
|
.get(FeedGroupDialogViewModel::class.java)
|
||||||
|
|
||||||
|
viewModel.groupLiveData.observe(viewLifecycleOwner, Observer(::handleGroup))
|
||||||
|
viewModel.subscriptionsLiveData.observe(viewLifecycleOwner, Observer { setupSubscriptionPicker(it.first, it.second) })
|
||||||
|
viewModel.dialogEventLiveData.observe(viewLifecycleOwner, Observer {
|
||||||
|
when (it) {
|
||||||
|
ProcessingEvent -> disableInput()
|
||||||
|
SuccessEvent -> dismiss()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setupIconPicker()
|
||||||
|
setupListeners()
|
||||||
|
|
||||||
|
showScreen(currentScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Setup
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
private fun setupListeners() {
|
||||||
|
delete_button.setOnClickListener { showScreen(DeleteScreen) }
|
||||||
|
|
||||||
|
cancel_button.setOnClickListener {
|
||||||
|
when (currentScreen) {
|
||||||
|
InitialScreen -> dismiss()
|
||||||
|
else -> showScreen(InitialScreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group_name_input_container.error = null
|
||||||
|
group_name_input.addTextChangedListener(object : TextWatcher {
|
||||||
|
override fun afterTextChanged(s: Editable?) {}
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||||
|
if (group_name_input_container.isErrorEnabled && !s.isNullOrBlank()) {
|
||||||
|
group_name_input_container.error = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
confirm_button.setOnClickListener {
|
||||||
|
when (currentScreen) {
|
||||||
|
InitialScreen -> handlePositiveButtonInitialScreen()
|
||||||
|
DeleteScreen -> viewModel.deleteGroup()
|
||||||
|
else -> showScreen(InitialScreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handlePositiveButtonInitialScreen() {
|
||||||
|
val name = group_name_input.text.toString().trim()
|
||||||
|
val icon = selectedIcon ?: groupIcon ?: FeedGroupIcon.ALL
|
||||||
|
|
||||||
|
if (name.isBlank()) {
|
||||||
|
group_name_input_container.error = getString(R.string.feed_group_dialog_empty_name)
|
||||||
|
group_name_input.text = null
|
||||||
|
group_name_input.requestFocus()
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
group_name_input_container.error = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedSubscriptions.isEmpty()) {
|
||||||
|
Toast.makeText(requireContext(), getString(R.string.feed_group_dialog_empty_selection), Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
when (groupId) {
|
||||||
|
NO_GROUP_SELECTED -> viewModel.createGroup(name, icon, selectedSubscriptions)
|
||||||
|
else -> viewModel.updateGroup(name, icon, selectedSubscriptions, groupSortOrder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleGroup(feedGroupEntity: FeedGroupEntity? = null) {
|
||||||
|
val icon = feedGroupEntity?.icon ?: FeedGroupIcon.ALL
|
||||||
|
val name = feedGroupEntity?.name ?: ""
|
||||||
|
groupIcon = feedGroupEntity?.icon
|
||||||
|
groupSortOrder = feedGroupEntity?.sortOrder ?: -1
|
||||||
|
|
||||||
|
icon_preview.setImageResource((if (selectedIcon == null) icon else selectedIcon!!).getDrawableRes(requireContext()))
|
||||||
|
|
||||||
|
if (group_name_input.text.isNullOrBlank()) {
|
||||||
|
group_name_input.setText(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupSubscriptionPicker(subscriptions: List<SubscriptionEntity>, selectedSubscriptions: Set<Long>) {
|
||||||
|
this.selectedSubscriptions.addAll(selectedSubscriptions)
|
||||||
|
val useGridLayout = subscriptions.isNotEmpty()
|
||||||
|
|
||||||
|
val groupAdapter = GroupAdapter<GroupieViewHolder>()
|
||||||
|
groupAdapter.spanCount = if (useGridLayout) 4 else 1
|
||||||
|
|
||||||
|
val subscriptionsCount = this.selectedSubscriptions.size
|
||||||
|
val selectedCountText = resources.getQuantityString(R.plurals.feed_group_dialog_selection_count, subscriptionsCount, subscriptionsCount)
|
||||||
|
selected_subscription_count_view.text = selectedCountText
|
||||||
|
subscriptions_selector_header_info.text = selectedCountText
|
||||||
|
|
||||||
|
Section().apply {
|
||||||
|
addAll(subscriptions.map {
|
||||||
|
val isSelected = this@FeedGroupDialog.selectedSubscriptions.contains(it.uid)
|
||||||
|
PickerSubscriptionItem(it, isSelected)
|
||||||
|
})
|
||||||
|
setPlaceholder(EmptyPlaceholderItem())
|
||||||
|
|
||||||
|
groupAdapter.add(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptions_selector_list.apply {
|
||||||
|
layoutManager = if (useGridLayout) {
|
||||||
|
GridLayoutManager(requireContext(), groupAdapter.spanCount, RecyclerView.VERTICAL, false)
|
||||||
|
} else {
|
||||||
|
LinearLayoutManager(requireContext(), RecyclerView.VERTICAL, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter = groupAdapter
|
||||||
|
|
||||||
|
if (subscriptionsListState != null) {
|
||||||
|
layoutManager?.onRestoreInstanceState(subscriptionsListState)
|
||||||
|
subscriptionsListState = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groupAdapter.setOnItemClickListener { item, _ ->
|
||||||
|
when (item) {
|
||||||
|
is PickerSubscriptionItem -> {
|
||||||
|
val subscriptionId = item.subscriptionEntity.uid
|
||||||
|
|
||||||
|
val isSelected = if (this.selectedSubscriptions.contains(subscriptionId)) {
|
||||||
|
this.selectedSubscriptions.remove(subscriptionId)
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
this.selectedSubscriptions.add(subscriptionId)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
item.isSelected = isSelected
|
||||||
|
item.notifyChanged(PickerSubscriptionItem.UPDATE_SELECTED)
|
||||||
|
|
||||||
|
val subscriptionsCount = this.selectedSubscriptions.size
|
||||||
|
val updateSelectedCountText = resources.getQuantityString(R.plurals.feed_group_dialog_selection_count, subscriptionsCount, subscriptionsCount)
|
||||||
|
selected_subscription_count_view.text = updateSelectedCountText
|
||||||
|
subscriptions_selector_header_info.text = updateSelectedCountText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select_channel_button.setOnClickListener {
|
||||||
|
subscriptions_selector_list.scrollToPosition(0)
|
||||||
|
showScreen(SubscriptionsPickerScreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupIconPicker() {
|
||||||
|
val groupAdapter = GroupAdapter<GroupieViewHolder>()
|
||||||
|
groupAdapter.addAll(FeedGroupIcon.values().map { PickerIconItem(requireContext(), it) })
|
||||||
|
|
||||||
|
icon_selector.apply {
|
||||||
|
layoutManager = GridLayoutManager(requireContext(), 7, RecyclerView.VERTICAL, false)
|
||||||
|
adapter = groupAdapter
|
||||||
|
|
||||||
|
if (iconsListState != null) {
|
||||||
|
layoutManager?.onRestoreInstanceState(iconsListState)
|
||||||
|
iconsListState = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groupAdapter.setOnItemClickListener { item, _ ->
|
||||||
|
when (item) {
|
||||||
|
is PickerIconItem -> {
|
||||||
|
selectedIcon = item.icon
|
||||||
|
icon_preview.setImageResource(item.iconRes)
|
||||||
|
|
||||||
|
showScreen(InitialScreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
icon_preview.setOnClickListener {
|
||||||
|
icon_selector.scrollToPosition(0)
|
||||||
|
showScreen(IconPickerScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupId == NO_GROUP_SELECTED) {
|
||||||
|
val icon = selectedIcon ?: FeedGroupIcon.ALL
|
||||||
|
icon_preview.setImageResource(icon.getDrawableRes(requireContext()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Screen Selector
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
private fun showScreen(screen: ScreenState) {
|
||||||
|
currentScreen = screen
|
||||||
|
|
||||||
|
options_root.onlyVisibleIn(InitialScreen)
|
||||||
|
icon_selector.onlyVisibleIn(IconPickerScreen)
|
||||||
|
subscriptions_selector.onlyVisibleIn(SubscriptionsPickerScreen)
|
||||||
|
delete_screen_message.onlyVisibleIn(DeleteScreen)
|
||||||
|
|
||||||
|
separator.onlyVisibleIn(SubscriptionsPickerScreen, IconPickerScreen)
|
||||||
|
cancel_button.onlyVisibleIn(InitialScreen, DeleteScreen)
|
||||||
|
|
||||||
|
confirm_button.setText(when {
|
||||||
|
currentScreen == InitialScreen && groupId == NO_GROUP_SELECTED -> R.string.create
|
||||||
|
else -> android.R.string.ok
|
||||||
|
})
|
||||||
|
|
||||||
|
delete_button.visibility = when {
|
||||||
|
currentScreen != InitialScreen -> View.GONE
|
||||||
|
groupId == NO_GROUP_SELECTED -> View.GONE
|
||||||
|
else -> View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentScreen != InitialScreen) hideKeyboard()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun View.onlyVisibleIn(vararg screens: ScreenState) {
|
||||||
|
visibility = when (currentScreen) {
|
||||||
|
in screens -> View.VISIBLE
|
||||||
|
else -> View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
// Utils
|
||||||
|
///////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
private fun hideKeyboard() {
|
||||||
|
val inputMethodManager = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
inputMethodManager.hideSoftInputFromWindow(group_name_input.windowToken, InputMethodManager.RESULT_UNCHANGED_SHOWN)
|
||||||
|
group_name_input.clearFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disableInput() {
|
||||||
|
delete_button?.isEnabled = false
|
||||||
|
confirm_button?.isEnabled = false
|
||||||
|
cancel_button?.isEnabled = false
|
||||||
|
isCancelable = false
|
||||||
|
|
||||||
|
hideKeyboard()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_GROUP_ID = "KEY_GROUP_ID"
|
||||||
|
private const val NO_GROUP_SELECTED = -1L
|
||||||
|
|
||||||
|
fun newInstance(groupId: Long = NO_GROUP_SELECTED): FeedGroupDialog {
|
||||||
|
val dialog = FeedGroupDialog()
|
||||||
|
|
||||||
|
dialog.arguments = Bundle().apply {
|
||||||
|
putLong(KEY_GROUP_ID, groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,87 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.dialog
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import io.reactivex.Completable
|
||||||
|
import io.reactivex.Flowable
|
||||||
|
import io.reactivex.disposables.Disposable
|
||||||
|
import io.reactivex.functions.BiFunction
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||||
|
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||||
|
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||||
|
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||||
|
|
||||||
|
|
||||||
|
class FeedGroupDialogViewModel(applicationContext: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModel() {
|
||||||
|
class Factory(val context: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModelProvider.Factory {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||||
|
return FeedGroupDialogViewModel(context.applicationContext, groupId) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
|
||||||
|
private var subscriptionManager = SubscriptionManager(applicationContext)
|
||||||
|
|
||||||
|
private val mutableGroupLiveData = MutableLiveData<FeedGroupEntity>()
|
||||||
|
private val mutableSubscriptionsLiveData = MutableLiveData<Pair<List<SubscriptionEntity>, Set<Long>>>()
|
||||||
|
private val mutableDialogEventLiveData = MutableLiveData<DialogEvent>()
|
||||||
|
val groupLiveData: LiveData<FeedGroupEntity> = mutableGroupLiveData
|
||||||
|
val subscriptionsLiveData: LiveData<Pair<List<SubscriptionEntity>, Set<Long>>> = mutableSubscriptionsLiveData
|
||||||
|
val dialogEventLiveData: LiveData<DialogEvent> = mutableDialogEventLiveData
|
||||||
|
|
||||||
|
private var actionProcessingDisposable: Disposable? = null
|
||||||
|
|
||||||
|
private var feedGroupDisposable = feedDatabaseManager.getGroup(groupId)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.subscribe(mutableGroupLiveData::postValue)
|
||||||
|
|
||||||
|
private var subscriptionsDisposable = Flowable
|
||||||
|
.combineLatest(subscriptionManager.subscriptions(), feedDatabaseManager.subscriptionIdsForGroup(groupId),
|
||||||
|
BiFunction { t1: List<SubscriptionEntity>, t2: List<Long> -> t1 to t2.toSet() })
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.subscribe(mutableSubscriptionsLiveData::postValue)
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
actionProcessingDisposable?.dispose()
|
||||||
|
subscriptionsDisposable.dispose()
|
||||||
|
feedGroupDisposable.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set<Long>) {
|
||||||
|
doAction(feedDatabaseManager.createGroup(name, selectedIcon)
|
||||||
|
.flatMapCompletable {
|
||||||
|
feedDatabaseManager.updateSubscriptionsForGroup(it, selectedSubscriptions.toList())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set<Long>, sortOrder: Long) {
|
||||||
|
doAction(feedDatabaseManager.updateSubscriptionsForGroup(groupId, selectedSubscriptions.toList())
|
||||||
|
.andThen(feedDatabaseManager.updateGroup(FeedGroupEntity(groupId, name, selectedIcon, sortOrder))))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteGroup() {
|
||||||
|
doAction(feedDatabaseManager.deleteGroup(groupId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun doAction(completable: Completable) {
|
||||||
|
if (actionProcessingDisposable == null) {
|
||||||
|
mutableDialogEventLiveData.value = DialogEvent.ProcessingEvent
|
||||||
|
|
||||||
|
actionProcessingDisposable = completable
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.subscribe { mutableDialogEventLiveData.postValue(DialogEvent.SuccessEvent) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class DialogEvent {
|
||||||
|
object ProcessingEvent : DialogEvent()
|
||||||
|
object SuccessEvent : DialogEvent()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,109 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.dialog
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import androidx.lifecycle.Observer
|
||||||
|
import androidx.lifecycle.ViewModelProviders
|
||||||
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
|
import androidx.recyclerview.widget.ItemTouchHelper.SimpleCallback
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.xwray.groupie.GroupAdapter
|
||||||
|
import com.xwray.groupie.TouchCallback
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||||
|
import icepick.Icepick
|
||||||
|
import icepick.State
|
||||||
|
import kotlinx.android.synthetic.main.dialog_feed_group_reorder.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
|
import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialogViewModel.DialogEvent.*
|
||||||
|
import org.schabi.newpipe.local.subscription.item.FeedGroupReorderItem
|
||||||
|
import org.schabi.newpipe.util.ThemeHelper
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
|
class FeedGroupReorderDialog : DialogFragment() {
|
||||||
|
private lateinit var viewModel: FeedGroupReorderDialogViewModel
|
||||||
|
|
||||||
|
@State @JvmField var groupOrderedIdList = ArrayList<Long>()
|
||||||
|
private val groupAdapter = GroupAdapter<GroupieViewHolder>()
|
||||||
|
private val itemTouchHelper = ItemTouchHelper(getItemTouchCallback())
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
Icepick.restoreInstanceState(this, savedInstanceState)
|
||||||
|
|
||||||
|
setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext()))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||||
|
return inflater.inflate(R.layout.dialog_feed_group_reorder, container)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
viewModel = ViewModelProviders.of(this).get(FeedGroupReorderDialogViewModel::class.java)
|
||||||
|
viewModel.groupsLiveData.observe(viewLifecycleOwner, Observer(::handleGroups))
|
||||||
|
viewModel.dialogEventLiveData.observe(viewLifecycleOwner, Observer {
|
||||||
|
when (it) {
|
||||||
|
ProcessingEvent -> disableInput()
|
||||||
|
SuccessEvent -> dismiss()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
feed_groups_list.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
feed_groups_list.adapter = groupAdapter
|
||||||
|
itemTouchHelper.attachToRecyclerView(feed_groups_list)
|
||||||
|
|
||||||
|
confirm_button.setOnClickListener {
|
||||||
|
viewModel.updateOrder(groupOrderedIdList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
Icepick.saveInstanceState(this, outState)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleGroups(list: List<FeedGroupEntity>) {
|
||||||
|
val groupList: List<FeedGroupEntity>
|
||||||
|
|
||||||
|
if (groupOrderedIdList.isEmpty()) {
|
||||||
|
groupList = list
|
||||||
|
groupOrderedIdList.addAll(groupList.map { it.uid })
|
||||||
|
} else {
|
||||||
|
groupList = list.sortedBy { groupOrderedIdList.indexOf(it.uid) }
|
||||||
|
}
|
||||||
|
|
||||||
|
groupAdapter.update(groupList.map { FeedGroupReorderItem(it, itemTouchHelper) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disableInput() {
|
||||||
|
confirm_button?.isEnabled = false
|
||||||
|
isCancelable = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getItemTouchCallback(): SimpleCallback {
|
||||||
|
return object : TouchCallback() {
|
||||||
|
|
||||||
|
override fun onMove(recyclerView: RecyclerView, source: RecyclerView.ViewHolder,
|
||||||
|
target: RecyclerView.ViewHolder): Boolean {
|
||||||
|
val sourceIndex = source.adapterPosition
|
||||||
|
val targetIndex = target.adapterPosition
|
||||||
|
|
||||||
|
groupAdapter.notifyItemMoved(sourceIndex, targetIndex)
|
||||||
|
Collections.swap(groupOrderedIdList, sourceIndex, targetIndex)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isLongPressDragEnabled(): Boolean = false
|
||||||
|
override fun isItemViewSwipeEnabled(): Boolean = false
|
||||||
|
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.dialog
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import io.reactivex.Completable
|
||||||
|
import io.reactivex.disposables.Disposable
|
||||||
|
import io.reactivex.schedulers.Schedulers
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
|
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||||
|
|
||||||
|
class FeedGroupReorderDialogViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application)
|
||||||
|
|
||||||
|
private val mutableGroupsLiveData = MutableLiveData<List<FeedGroupEntity>>()
|
||||||
|
private val mutableDialogEventLiveData = MutableLiveData<DialogEvent>()
|
||||||
|
val groupsLiveData: LiveData<List<FeedGroupEntity>> = mutableGroupsLiveData
|
||||||
|
val dialogEventLiveData: LiveData<DialogEvent> = mutableDialogEventLiveData
|
||||||
|
|
||||||
|
private var actionProcessingDisposable: Disposable? = null
|
||||||
|
|
||||||
|
private var groupsDisposable = feedDatabaseManager.groups()
|
||||||
|
.limit(1)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.subscribe(mutableGroupsLiveData::postValue)
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
actionProcessingDisposable?.dispose()
|
||||||
|
groupsDisposable.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateOrder(groupIdList: List<Long>) {
|
||||||
|
doAction(feedDatabaseManager.updateGroupsOrder(groupIdList))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun doAction(completable: Completable) {
|
||||||
|
if (actionProcessingDisposable == null) {
|
||||||
|
mutableDialogEventLiveData.value = DialogEvent.ProcessingEvent
|
||||||
|
|
||||||
|
actionProcessingDisposable = completable
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.subscribe { mutableDialogEventLiveData.postValue(DialogEvent.SuccessEvent) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class DialogEvent {
|
||||||
|
object ProcessingEvent : DialogEvent()
|
||||||
|
object SuccessEvent : DialogEvent()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.nostra13.universalimageloader.core.ImageLoader
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||||
|
import kotlinx.android.synthetic.main.list_channel_item.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
|
||||||
|
import org.schabi.newpipe.util.ImageDisplayConstants
|
||||||
|
import org.schabi.newpipe.util.Localization
|
||||||
|
import org.schabi.newpipe.util.OnClickGesture
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelItem(
|
||||||
|
private val infoItem: ChannelInfoItem,
|
||||||
|
private val subscriptionId: Long = -1L,
|
||||||
|
var itemVersion: ItemVersion = ItemVersion.NORMAL,
|
||||||
|
var gesturesListener: OnClickGesture<ChannelInfoItem>? = null
|
||||||
|
) : Item() {
|
||||||
|
|
||||||
|
override fun getId(): Long = if (subscriptionId == -1L) super.getId() else subscriptionId
|
||||||
|
|
||||||
|
enum class ItemVersion { NORMAL, MINI, GRID }
|
||||||
|
|
||||||
|
override fun getLayout(): Int = when (itemVersion) {
|
||||||
|
ItemVersion.NORMAL -> R.layout.list_channel_item
|
||||||
|
ItemVersion.MINI -> R.layout.list_channel_mini_item
|
||||||
|
ItemVersion.GRID -> R.layout.list_channel_grid_item
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||||
|
viewHolder.itemTitleView.text = infoItem.name
|
||||||
|
viewHolder.itemAdditionalDetails.text = getDetailLine(viewHolder.root.context)
|
||||||
|
if (itemVersion == ItemVersion.NORMAL) viewHolder.itemChannelDescriptionView.text = infoItem.description
|
||||||
|
|
||||||
|
ImageLoader.getInstance().displayImage(infoItem.thumbnailUrl, viewHolder.itemThumbnailView,
|
||||||
|
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS)
|
||||||
|
|
||||||
|
gesturesListener?.run {
|
||||||
|
viewHolder.containerView.setOnClickListener { selected(infoItem) }
|
||||||
|
viewHolder.containerView.setOnLongClickListener { held(infoItem); true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDetailLine(context: Context): String {
|
||||||
|
var details = if (infoItem.subscriberCount >= 0) {
|
||||||
|
Localization.shortSubscriberCount(context, infoItem.subscriberCount)
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.subscribers_count_not_available)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemVersion == ItemVersion.NORMAL) {
|
||||||
|
if (infoItem.streamCount >= 0) {
|
||||||
|
val formattedVideoAmount = Localization.localizeStreamCount(context, infoItem.streamCount)
|
||||||
|
details = Localization.concatenateStrings(details, formattedVideoAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSpanSize(spanCount: Int, position: Int): Int {
|
||||||
|
return if (itemVersion == ItemVersion.GRID) 1 else spanCount
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
|
||||||
|
class EmptyPlaceholderItem : Item() {
|
||||||
|
override fun getLayout(): Int = R.layout.list_empty_view
|
||||||
|
override fun bind(viewHolder: GroupieViewHolder, position: Int) {}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
|
||||||
|
class FeedGroupAddItem : Item() {
|
||||||
|
override fun getLayout(): Int = R.layout.feed_group_add_new_item
|
||||||
|
override fun bind(viewHolder: GroupieViewHolder, position: Int) {}
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||||
|
import kotlinx.android.synthetic.main.feed_group_card_item.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
|
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||||
|
|
||||||
|
data class FeedGroupCardItem(
|
||||||
|
val groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
|
||||||
|
val name: String,
|
||||||
|
val icon: FeedGroupIcon
|
||||||
|
) : Item() {
|
||||||
|
constructor (feedGroupEntity: FeedGroupEntity) : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon)
|
||||||
|
|
||||||
|
override fun getId(): Long {
|
||||||
|
return when (groupId) {
|
||||||
|
FeedGroupEntity.GROUP_ALL_ID -> super.getId()
|
||||||
|
else -> groupId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLayout(): Int = R.layout.feed_group_card_item
|
||||||
|
|
||||||
|
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||||
|
viewHolder.title.text = name
|
||||||
|
viewHolder.icon.setImageResource(icon.getDrawableRes(viewHolder.containerView.context))
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Parcelable
|
||||||
|
import android.view.View
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.xwray.groupie.GroupAdapter
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||||
|
import kotlinx.android.synthetic.main.feed_item_carousel.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.local.subscription.decoration.FeedGroupCarouselDecoration
|
||||||
|
|
||||||
|
class FeedGroupCarouselItem(context: Context, private val carouselAdapter: GroupAdapter<GroupieViewHolder>) : Item() {
|
||||||
|
private val feedGroupCarouselDecoration = FeedGroupCarouselDecoration(context)
|
||||||
|
|
||||||
|
private var linearLayoutManager: LinearLayoutManager? = null
|
||||||
|
private var listState: Parcelable? = null
|
||||||
|
|
||||||
|
override fun getLayout() = R.layout.feed_item_carousel
|
||||||
|
|
||||||
|
fun onSaveInstanceState(): Parcelable? {
|
||||||
|
listState = linearLayoutManager?.onSaveInstanceState()
|
||||||
|
return listState
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onRestoreInstanceState(state: Parcelable?) {
|
||||||
|
linearLayoutManager?.onRestoreInstanceState(state)
|
||||||
|
listState = state
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createViewHolder(itemView: View): GroupieViewHolder {
|
||||||
|
val viewHolder = super.createViewHolder(itemView)
|
||||||
|
|
||||||
|
linearLayoutManager = LinearLayoutManager(itemView.context, RecyclerView.HORIZONTAL, false)
|
||||||
|
|
||||||
|
viewHolder.recycler_view.apply {
|
||||||
|
layoutManager = linearLayoutManager
|
||||||
|
adapter = carouselAdapter
|
||||||
|
addItemDecoration(feedGroupCarouselDecoration)
|
||||||
|
}
|
||||||
|
|
||||||
|
return viewHolder
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||||
|
viewHolder.recycler_view.apply { adapter = carouselAdapter }
|
||||||
|
linearLayoutManager?.onRestoreInstanceState(listState)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unbind(viewHolder: GroupieViewHolder) {
|
||||||
|
super.unbind(viewHolder)
|
||||||
|
|
||||||
|
listState = linearLayoutManager?.onSaveInstanceState()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
|
import androidx.recyclerview.widget.ItemTouchHelper.DOWN
|
||||||
|
import androidx.recyclerview.widget.ItemTouchHelper.UP
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import kotlinx.android.synthetic.main.feed_group_reorder_item.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||||
|
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||||
|
|
||||||
|
data class FeedGroupReorderItem(
|
||||||
|
val groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
|
||||||
|
val name: String,
|
||||||
|
val icon: FeedGroupIcon,
|
||||||
|
val dragCallback: ItemTouchHelper
|
||||||
|
) : Item() {
|
||||||
|
constructor (feedGroupEntity: FeedGroupEntity, dragCallback: ItemTouchHelper)
|
||||||
|
: this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon, dragCallback)
|
||||||
|
|
||||||
|
override fun getId(): Long {
|
||||||
|
return when (groupId) {
|
||||||
|
FeedGroupEntity.GROUP_ALL_ID -> super.getId()
|
||||||
|
else -> groupId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLayout(): Int = R.layout.feed_group_reorder_item
|
||||||
|
|
||||||
|
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||||
|
viewHolder.group_name.text = name
|
||||||
|
viewHolder.group_icon.setImageResource(icon.getDrawableRes(viewHolder.containerView.context))
|
||||||
|
viewHolder.handle.setOnTouchListener { _, event ->
|
||||||
|
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||||
|
dragCallback.startDrag(viewHolder)
|
||||||
|
return@setOnTouchListener true
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDragDirs(): Int {
|
||||||
|
return UP or DOWN
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,116 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.PorterDuff
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import kotlinx.android.synthetic.main.feed_import_export_group.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.extractor.NewPipe
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.ExtractionException
|
||||||
|
import org.schabi.newpipe.util.AnimationUtils
|
||||||
|
import org.schabi.newpipe.util.ServiceHelper
|
||||||
|
import org.schabi.newpipe.util.ThemeHelper
|
||||||
|
import org.schabi.newpipe.views.CollapsibleView
|
||||||
|
|
||||||
|
class FeedImportExportItem(
|
||||||
|
val onImportPreviousSelected: () -> Unit,
|
||||||
|
val onImportFromServiceSelected: (Int) -> Unit,
|
||||||
|
val onExportSelected: () -> Unit,
|
||||||
|
var isExpanded: Boolean = false
|
||||||
|
) : Item() {
|
||||||
|
companion object {
|
||||||
|
const val REFRESH_EXPANDED_STATUS = 123
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList<Any>) {
|
||||||
|
if (payloads.contains(REFRESH_EXPANDED_STATUS)) {
|
||||||
|
viewHolder.import_export_options.apply { if (isExpanded) expand() else collapse() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
super.bind(viewHolder, position, payloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLayout(): Int = R.layout.feed_import_export_group
|
||||||
|
|
||||||
|
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||||
|
if (viewHolder.import_from_options.childCount == 0) setupImportFromItems(viewHolder.import_from_options)
|
||||||
|
if (viewHolder.export_to_options.childCount == 0) setupExportToItems(viewHolder.export_to_options)
|
||||||
|
|
||||||
|
expandIconListener?.let { viewHolder.import_export_options.removeListener(it) }
|
||||||
|
expandIconListener = CollapsibleView.StateListener { newState ->
|
||||||
|
AnimationUtils.animateRotation(viewHolder.import_export_expand_icon,
|
||||||
|
250, if (newState == CollapsibleView.COLLAPSED) 0 else 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewHolder.import_export_options.currentState = if (isExpanded) CollapsibleView.EXPANDED else CollapsibleView.COLLAPSED
|
||||||
|
viewHolder.import_export_expand_icon.rotation = if (isExpanded) 180F else 0F
|
||||||
|
viewHolder.import_export_options.ready()
|
||||||
|
|
||||||
|
viewHolder.import_export_options.addListener(expandIconListener)
|
||||||
|
viewHolder.import_export.setOnClickListener {
|
||||||
|
viewHolder.import_export_options.switchState()
|
||||||
|
isExpanded = viewHolder.import_export_options.currentState == CollapsibleView.EXPANDED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unbind(viewHolder: GroupieViewHolder) {
|
||||||
|
super.unbind(viewHolder)
|
||||||
|
expandIconListener?.let { viewHolder.import_export_options.removeListener(it) }
|
||||||
|
expandIconListener = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private var expandIconListener: CollapsibleView.StateListener? = null
|
||||||
|
|
||||||
|
private fun addItemView(title: String, @DrawableRes icon: Int, container: ViewGroup): View {
|
||||||
|
val itemRoot = View.inflate(container.context, R.layout.subscription_import_export_item, null)
|
||||||
|
val titleView = itemRoot.findViewById<TextView>(android.R.id.text1)
|
||||||
|
val iconView = itemRoot.findViewById<ImageView>(android.R.id.icon1)
|
||||||
|
|
||||||
|
titleView.text = title
|
||||||
|
iconView.setImageResource(icon)
|
||||||
|
|
||||||
|
container.addView(itemRoot)
|
||||||
|
return itemRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupImportFromItems(listHolder: ViewGroup) {
|
||||||
|
val previousBackupItem = addItemView(listHolder.context.getString(R.string.previous_export),
|
||||||
|
ThemeHelper.resolveResourceIdFromAttr(listHolder.context, R.attr.ic_backup), listHolder)
|
||||||
|
previousBackupItem.setOnClickListener { onImportPreviousSelected() }
|
||||||
|
|
||||||
|
val iconColor = if (ThemeHelper.isLightThemeSelected(listHolder.context)) Color.BLACK else Color.WHITE
|
||||||
|
val services = listHolder.context.resources.getStringArray(R.array.service_list)
|
||||||
|
for (serviceName in services) {
|
||||||
|
try {
|
||||||
|
val service = NewPipe.getService(serviceName)
|
||||||
|
|
||||||
|
val subscriptionExtractor = service.subscriptionExtractor ?: continue
|
||||||
|
|
||||||
|
val supportedSources = subscriptionExtractor.supportedSources
|
||||||
|
if (supportedSources.isEmpty()) continue
|
||||||
|
|
||||||
|
val itemView = addItemView(serviceName, ServiceHelper.getIcon(service.serviceId), listHolder)
|
||||||
|
val iconView = itemView.findViewById<ImageView>(android.R.id.icon1)
|
||||||
|
iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN)
|
||||||
|
|
||||||
|
itemView.setOnClickListener { onImportFromServiceSelected(service.serviceId) }
|
||||||
|
} catch (e: ExtractionException) {
|
||||||
|
throw RuntimeException("Services array contains an entry that it's not a valid service name ($serviceName)", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupExportToItems(listHolder: ViewGroup) {
|
||||||
|
val previousBackupItem = addItemView(listHolder.context.getString(R.string.file),
|
||||||
|
ThemeHelper.resolveResourceIdFromAttr(listHolder.context, R.attr.ic_save), listHolder)
|
||||||
|
previousBackupItem.setOnClickListener { onExportSelected() }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import android.view.View.OnClickListener
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||||
|
import kotlinx.android.synthetic.main.header_item.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
|
||||||
|
class HeaderItem(val title: String, private val onClickListener: (() -> Unit)? = null) : Item() {
|
||||||
|
|
||||||
|
override fun getLayout(): Int = R.layout.header_item
|
||||||
|
|
||||||
|
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||||
|
viewHolder.header_title.text = title
|
||||||
|
|
||||||
|
val listener: OnClickListener? = if (onClickListener != null) OnClickListener { onClickListener.invoke() } else null
|
||||||
|
viewHolder.root.setOnClickListener(listener)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import android.view.View.*
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import kotlinx.android.synthetic.main.header_with_menu_item.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
|
||||||
|
class HeaderWithMenuItem(
|
||||||
|
val title: String,
|
||||||
|
@DrawableRes val itemIcon: Int = 0,
|
||||||
|
private val onClickListener: (() -> Unit)? = null,
|
||||||
|
private val menuItemOnClickListener: (() -> Unit)? = null
|
||||||
|
) : Item() {
|
||||||
|
companion object {
|
||||||
|
const val PAYLOAD_SHOW_MENU_ITEM = 1
|
||||||
|
const val PAYLOAD_HIDE_MENU_ITEM = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLayout(): Int = R.layout.header_with_menu_item
|
||||||
|
|
||||||
|
|
||||||
|
override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList<Any>) {
|
||||||
|
if (payloads.contains(PAYLOAD_SHOW_MENU_ITEM)) {
|
||||||
|
viewHolder.header_menu_item.visibility = VISIBLE
|
||||||
|
return
|
||||||
|
} else if (payloads.contains(PAYLOAD_HIDE_MENU_ITEM)) {
|
||||||
|
viewHolder.header_menu_item.visibility = GONE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
super.bind(viewHolder, position, payloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||||
|
viewHolder.header_title.text = title
|
||||||
|
viewHolder.header_menu_item.setImageResource(itemIcon)
|
||||||
|
|
||||||
|
val listener: OnClickListener? =
|
||||||
|
onClickListener?.let { OnClickListener { onClickListener.invoke() } }
|
||||||
|
viewHolder.root.setOnClickListener(listener)
|
||||||
|
|
||||||
|
val menuItemListener: OnClickListener? =
|
||||||
|
menuItemOnClickListener?.let { OnClickListener { menuItemOnClickListener.invoke() } }
|
||||||
|
viewHolder.header_menu_item.setOnClickListener(menuItemListener)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||||
|
import kotlinx.android.synthetic.main.picker_icon_item.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.local.subscription.FeedGroupIcon
|
||||||
|
|
||||||
|
class PickerIconItem(context: Context, val icon: FeedGroupIcon) : Item() {
|
||||||
|
@DrawableRes val iconRes: Int = icon.getDrawableRes(context)
|
||||||
|
|
||||||
|
override fun getLayout(): Int = R.layout.picker_icon_item
|
||||||
|
|
||||||
|
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||||
|
viewHolder.icon_view.setImageResource(iconRes)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
package org.schabi.newpipe.local.subscription.item
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import com.nostra13.universalimageloader.core.DisplayImageOptions
|
||||||
|
import com.nostra13.universalimageloader.core.ImageLoader
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.Item
|
||||||
|
import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder
|
||||||
|
import kotlinx.android.synthetic.main.picker_subscription_item.*
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||||
|
import org.schabi.newpipe.util.AnimationUtils
|
||||||
|
import org.schabi.newpipe.util.AnimationUtils.animateView
|
||||||
|
import org.schabi.newpipe.util.ImageDisplayConstants
|
||||||
|
|
||||||
|
data class PickerSubscriptionItem(val subscriptionEntity: SubscriptionEntity, var isSelected: Boolean = false) : Item() {
|
||||||
|
companion object {
|
||||||
|
const val UPDATE_SELECTED = 123
|
||||||
|
|
||||||
|
val IMAGE_LOADING_OPTIONS: DisplayImageOptions = ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLayout(): Int = R.layout.picker_subscription_item
|
||||||
|
|
||||||
|
override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList<Any>) {
|
||||||
|
if (payloads.contains(UPDATE_SELECTED)) {
|
||||||
|
animateView(viewHolder.selected_highlight, AnimationUtils.Type.LIGHT_SCALE_AND_ALPHA, isSelected, 150)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
super.bind(viewHolder, position, payloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||||
|
ImageLoader.getInstance().displayImage(subscriptionEntity.avatarUrl, viewHolder.thumbnail_view, IMAGE_LOADING_OPTIONS)
|
||||||
|
|
||||||
|
viewHolder.title_view.text = subscriptionEntity.name
|
||||||
|
viewHolder.selected_highlight.visibility = if (isSelected) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun unbind(viewHolder: GroupieViewHolder) {
|
||||||
|
super.unbind(viewHolder)
|
||||||
|
|
||||||
|
viewHolder.selected_highlight.animate().setListener(null).cancel()
|
||||||
|
viewHolder.selected_highlight.visibility = View.GONE
|
||||||
|
viewHolder.selected_highlight.alpha = 1F
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getId(): Long {
|
||||||
|
return subscriptionEntity.uid
|
||||||
|
}
|
||||||
|
}
|
|
@ -34,10 +34,9 @@ import android.widget.Toast;
|
||||||
import org.reactivestreams.Publisher;
|
import org.reactivestreams.Publisher;
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
|
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
|
||||||
|
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||||
import org.schabi.newpipe.report.ErrorActivity;
|
import org.schabi.newpipe.report.ErrorActivity;
|
||||||
import org.schabi.newpipe.report.UserAction;
|
import org.schabi.newpipe.report.UserAction;
|
||||||
import org.schabi.newpipe.local.subscription.ImportExportEventListener;
|
|
||||||
import org.schabi.newpipe.local.subscription.SubscriptionService;
|
|
||||||
|
|
||||||
import java.io.FileNotFoundException;
|
import java.io.FileNotFoundException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
@ -57,7 +56,7 @@ public abstract class BaseImportExportService extends Service {
|
||||||
protected NotificationManagerCompat notificationManager;
|
protected NotificationManagerCompat notificationManager;
|
||||||
protected NotificationCompat.Builder notificationBuilder;
|
protected NotificationCompat.Builder notificationBuilder;
|
||||||
|
|
||||||
protected SubscriptionService subscriptionService;
|
protected SubscriptionManager subscriptionManager;
|
||||||
protected final CompositeDisposable disposables = new CompositeDisposable();
|
protected final CompositeDisposable disposables = new CompositeDisposable();
|
||||||
protected final PublishProcessor<String> notificationUpdater = PublishProcessor.create();
|
protected final PublishProcessor<String> notificationUpdater = PublishProcessor.create();
|
||||||
|
|
||||||
|
@ -70,7 +69,7 @@ public abstract class BaseImportExportService extends Service {
|
||||||
@Override
|
@Override
|
||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
super.onCreate();
|
super.onCreate();
|
||||||
subscriptionService = SubscriptionService.getInstance(this);
|
subscriptionManager = new SubscriptionManager(this);
|
||||||
setupNotification();
|
setupNotification();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
package org.schabi.newpipe.local.subscription;
|
package org.schabi.newpipe.local.subscription.services;
|
||||||
|
|
||||||
public interface ImportExportEventListener {
|
public interface ImportExportEventListener {
|
||||||
/**
|
/**
|
|
@ -17,7 +17,7 @@
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package org.schabi.newpipe.local.subscription;
|
package org.schabi.newpipe.local.subscription.services;
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
|
@ -29,7 +29,6 @@ import org.reactivestreams.Subscription;
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||||
import org.schabi.newpipe.local.subscription.ImportExportJsonHelper;
|
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileNotFoundException;
|
import java.io.FileNotFoundException;
|
||||||
|
@ -96,7 +95,7 @@ public class SubscriptionsExportService extends BaseImportExportService {
|
||||||
private void startExport() {
|
private void startExport() {
|
||||||
showToast(R.string.export_ongoing);
|
showToast(R.string.export_ongoing);
|
||||||
|
|
||||||
subscriptionService.subscriptionTable()
|
subscriptionManager.subscriptionTable()
|
||||||
.getAll()
|
.getAll()
|
||||||
.take(1)
|
.take(1)
|
||||||
.map(subscriptionEntities -> {
|
.map(subscriptionEntities -> {
|
||||||
|
|
|
@ -33,7 +33,6 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||||
import org.schabi.newpipe.extractor.NewPipe;
|
import org.schabi.newpipe.extractor.NewPipe;
|
||||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||||
import org.schabi.newpipe.local.subscription.ImportExportJsonHelper;
|
|
||||||
import org.schabi.newpipe.util.Constants;
|
import org.schabi.newpipe.util.Constants;
|
||||||
import org.schabi.newpipe.util.ExtractorHelper;
|
import org.schabi.newpipe.util.ExtractorHelper;
|
||||||
|
|
||||||
|
@ -180,6 +179,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
|
||||||
|
|
||||||
.observeOn(Schedulers.io())
|
.observeOn(Schedulers.io())
|
||||||
.doOnNext(getNotificationsConsumer())
|
.doOnNext(getNotificationsConsumer())
|
||||||
|
|
||||||
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
||||||
.map(upsertBatch())
|
.map(upsertBatch())
|
||||||
|
|
||||||
|
@ -204,6 +204,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onError(Throwable error) {
|
public void onError(Throwable error) {
|
||||||
|
Log.e(TAG, "Got an error!", error);
|
||||||
handleError(error);
|
handleError(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -242,7 +243,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
|
||||||
if (n.isOnNext()) infoList.add(n.getValue());
|
if (n.isOnNext()) infoList.add(n.getValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
return subscriptionService.upsertAll(infoList);
|
return subscriptionManager.upsertAll(infoList);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -30,16 +30,16 @@ import android.content.res.Resources;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.annotation.RequiresApi;
|
|
||||||
import androidx.core.app.NotificationCompat;
|
|
||||||
|
|
||||||
import android.preference.PreferenceManager;
|
import android.preference.PreferenceManager;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.widget.RemoteViews;
|
import android.widget.RemoteViews;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.annotation.RequiresApi;
|
||||||
|
import androidx.core.app.NotificationCompat;
|
||||||
|
|
||||||
import com.google.android.exoplayer2.PlaybackParameters;
|
import com.google.android.exoplayer2.PlaybackParameters;
|
||||||
import com.google.android.exoplayer2.Player;
|
import com.google.android.exoplayer2.Player;
|
||||||
import com.google.android.exoplayer2.source.MediaSource;
|
import com.google.android.exoplayer2.source.MediaSource;
|
||||||
|
@ -341,7 +341,7 @@ public final class BackgroundPlayer extends Service {
|
||||||
@Override
|
@Override
|
||||||
public void handleIntent(final Intent intent) {
|
public void handleIntent(final Intent intent) {
|
||||||
super.handleIntent(intent);
|
super.handleIntent(intent);
|
||||||
|
|
||||||
resetNotification();
|
resetNotification();
|
||||||
if (bigNotRemoteView != null)
|
if (bigNotRemoteView != null)
|
||||||
bigNotRemoteView.setProgressBar(R.id.notificationProgressBar, 100, 0, false);
|
bigNotRemoteView.setProgressBar(R.id.notificationProgressBar, 100, 0, false);
|
||||||
|
@ -389,7 +389,6 @@ public final class BackgroundPlayer extends Service {
|
||||||
@Override
|
@Override
|
||||||
public void onPrepared(boolean playWhenReady) {
|
public void onPrepared(boolean playWhenReady) {
|
||||||
super.onPrepared(playWhenReady);
|
super.onPrepared(playWhenReady);
|
||||||
simpleExoPlayer.setVolume(1f);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -398,6 +397,12 @@ public final class BackgroundPlayer extends Service {
|
||||||
updatePlayback();
|
updatePlayback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMuteUnmuteButtonClicked() {
|
||||||
|
super.onMuteUnmuteButtonClicked();
|
||||||
|
updatePlayback();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onUpdateProgress(int currentProgress, int duration, int bufferPercent) {
|
public void onUpdateProgress(int currentProgress, int duration, int bufferPercent) {
|
||||||
updateProgress(currentProgress, duration, bufferPercent);
|
updateProgress(currentProgress, duration, bufferPercent);
|
||||||
|
|
|
@ -153,6 +153,8 @@ public abstract class BasePlayer implements
|
||||||
public static final String START_PAUSED = "start_paused";
|
public static final String START_PAUSED = "start_paused";
|
||||||
@NonNull
|
@NonNull
|
||||||
public static final String SELECT_ON_APPEND = "select_on_append";
|
public static final String SELECT_ON_APPEND = "select_on_append";
|
||||||
|
@NonNull
|
||||||
|
public static final String IS_MUTED = "is_muted";
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
// Playback
|
// Playback
|
||||||
|
@ -275,6 +277,7 @@ public abstract class BasePlayer implements
|
||||||
final float playbackPitch = intent.getFloatExtra(PLAYBACK_PITCH, getPlaybackPitch());
|
final float playbackPitch = intent.getFloatExtra(PLAYBACK_PITCH, getPlaybackPitch());
|
||||||
final boolean playbackSkipSilence = intent.getBooleanExtra(PLAYBACK_SKIP_SILENCE,
|
final boolean playbackSkipSilence = intent.getBooleanExtra(PLAYBACK_SKIP_SILENCE,
|
||||||
getPlaybackSkipSilence());
|
getPlaybackSkipSilence());
|
||||||
|
final boolean isMuted = intent.getBooleanExtra(IS_MUTED, simpleExoPlayer == null ? false : isMuted());
|
||||||
|
|
||||||
// seek to timestamp if stream is already playing
|
// seek to timestamp if stream is already playing
|
||||||
if (simpleExoPlayer != null
|
if (simpleExoPlayer != null
|
||||||
|
@ -283,7 +286,7 @@ public abstract class BasePlayer implements
|
||||||
&& playQueue.getItem() != null
|
&& playQueue.getItem() != null
|
||||||
&& queue.getItem().getUrl().equals(playQueue.getItem().getUrl())
|
&& queue.getItem().getUrl().equals(playQueue.getItem().getUrl())
|
||||||
&& queue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET
|
&& queue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET
|
||||||
) {
|
) {
|
||||||
simpleExoPlayer.seekTo(playQueue.getIndex(), queue.getItem().getRecoveryPosition());
|
simpleExoPlayer.seekTo(playQueue.getIndex(), queue.getItem().getRecoveryPosition());
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
@ -293,7 +296,7 @@ public abstract class BasePlayer implements
|
||||||
stateLoader = recordManager.loadStreamState(item)
|
stateLoader = recordManager.loadStreamState(item)
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.doFinally(() -> initPlayback(queue, repeatMode, playbackSpeed, playbackPitch, playbackSkipSilence,
|
.doFinally(() -> initPlayback(queue, repeatMode, playbackSpeed, playbackPitch, playbackSkipSilence,
|
||||||
/*playOnInit=*/true))
|
/*playOnInit=*/true, isMuted))
|
||||||
.subscribe(
|
.subscribe(
|
||||||
state -> queue.setRecovery(queue.getIndex(), state.getProgressTime()),
|
state -> queue.setRecovery(queue.getIndex(), state.getProgressTime()),
|
||||||
error -> {
|
error -> {
|
||||||
|
@ -306,7 +309,7 @@ public abstract class BasePlayer implements
|
||||||
}
|
}
|
||||||
// Good to go...
|
// Good to go...
|
||||||
initPlayback(queue, repeatMode, playbackSpeed, playbackPitch, playbackSkipSilence,
|
initPlayback(queue, repeatMode, playbackSpeed, playbackPitch, playbackSkipSilence,
|
||||||
/*playOnInit=*/!intent.getBooleanExtra(START_PAUSED, false));
|
/*playOnInit=*/!intent.getBooleanExtra(START_PAUSED, false), isMuted);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void initPlayback(@NonNull final PlayQueue queue,
|
protected void initPlayback(@NonNull final PlayQueue queue,
|
||||||
|
@ -314,7 +317,8 @@ public abstract class BasePlayer implements
|
||||||
final float playbackSpeed,
|
final float playbackSpeed,
|
||||||
final float playbackPitch,
|
final float playbackPitch,
|
||||||
final boolean playbackSkipSilence,
|
final boolean playbackSkipSilence,
|
||||||
final boolean playOnReady) {
|
final boolean playOnReady,
|
||||||
|
final boolean isMuted) {
|
||||||
destroyPlayer();
|
destroyPlayer();
|
||||||
initPlayer(playOnReady);
|
initPlayer(playOnReady);
|
||||||
setRepeatMode(repeatMode);
|
setRepeatMode(repeatMode);
|
||||||
|
@ -327,6 +331,8 @@ public abstract class BasePlayer implements
|
||||||
|
|
||||||
if (playQueueAdapter != null) playQueueAdapter.dispose();
|
if (playQueueAdapter != null) playQueueAdapter.dispose();
|
||||||
playQueueAdapter = new PlayQueueAdapter(context, playQueue);
|
playQueueAdapter = new PlayQueueAdapter(context, playQueue);
|
||||||
|
|
||||||
|
simpleExoPlayer.setVolume(isMuted ? 0 : 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void destroyPlayer() {
|
public void destroyPlayer() {
|
||||||
|
@ -532,6 +538,18 @@ public abstract class BasePlayer implements
|
||||||
if (simpleExoPlayer == null) return;
|
if (simpleExoPlayer == null) return;
|
||||||
simpleExoPlayer.setShuffleModeEnabled(!simpleExoPlayer.getShuffleModeEnabled());
|
simpleExoPlayer.setShuffleModeEnabled(!simpleExoPlayer.getShuffleModeEnabled());
|
||||||
}
|
}
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Mute / Unmute
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
public void onMuteUnmuteButtonClicked() {
|
||||||
|
if (DEBUG) Log.d(TAG, "onMuteUnmuteButtonClicled() called");
|
||||||
|
simpleExoPlayer.setVolume(isMuted() ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isMuted() {
|
||||||
|
return simpleExoPlayer.getVolume() == 0;
|
||||||
|
}
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
// Progress Updates
|
// Progress Updates
|
||||||
|
|
|
@ -34,14 +34,17 @@ import android.os.Bundle;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.preference.PreferenceManager;
|
import android.preference.PreferenceManager;
|
||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
|
|
||||||
import androidx.annotation.ColorInt;
|
import androidx.annotation.ColorInt;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.core.app.ActivityCompat;
|
import androidx.core.app.ActivityCompat;
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
import androidx.appcompat.content.res.AppCompatResources;
|
import androidx.appcompat.content.res.AppCompatResources;
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||||
|
|
||||||
import android.util.DisplayMetrics;
|
import android.util.DisplayMetrics;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.util.TypedValue;
|
import android.util.TypedValue;
|
||||||
|
@ -116,7 +119,8 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
|
|
||||||
private SharedPreferences defaultPreferences;
|
private SharedPreferences defaultPreferences;
|
||||||
|
|
||||||
@Nullable private PlayerState playerState;
|
@Nullable
|
||||||
|
private PlayerState playerState;
|
||||||
private boolean isInMultiWindow;
|
private boolean isInMultiWindow;
|
||||||
private boolean isBackPressed;
|
private boolean isBackPressed;
|
||||||
|
|
||||||
|
@ -130,11 +134,13 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||||
assureCorrectAppLanguage(this);
|
assureCorrectAppLanguage(this);
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
if (DEBUG) Log.d(TAG, "onCreate() called with: savedInstanceState = [" + savedInstanceState + "]");
|
if (DEBUG)
|
||||||
|
Log.d(TAG, "onCreate() called with: savedInstanceState = [" + savedInstanceState + "]");
|
||||||
defaultPreferences = PreferenceManager.getDefaultSharedPreferences(this);
|
defaultPreferences = PreferenceManager.getDefaultSharedPreferences(this);
|
||||||
ThemeHelper.setTheme(this);
|
ThemeHelper.setTheme(this);
|
||||||
getWindow().setBackgroundDrawable(new ColorDrawable(Color.BLACK));
|
getWindow().setBackgroundDrawable(new ColorDrawable(Color.BLACK));
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) getWindow().setStatusBarColor(Color.BLACK);
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
|
||||||
|
getWindow().setStatusBarColor(Color.BLACK);
|
||||||
setVolumeControlStream(AudioManager.STREAM_MUSIC);
|
setVolumeControlStream(AudioManager.STREAM_MUSIC);
|
||||||
|
|
||||||
WindowManager.LayoutParams lp = getWindow().getAttributes();
|
WindowManager.LayoutParams lp = getWindow().getAttributes();
|
||||||
|
@ -143,7 +149,7 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
|
|
||||||
hideSystemUi();
|
hideSystemUi();
|
||||||
setContentView(R.layout.activity_main_player);
|
setContentView(R.layout.activity_main_player);
|
||||||
playerImpl = new VideoPlayerImpl(this);
|
playerImpl = new VideoPlayerImpl(this);
|
||||||
playerImpl.setup(findViewById(android.R.id.content));
|
playerImpl.setup(findViewById(android.R.id.content));
|
||||||
|
|
||||||
if (savedInstanceState != null && savedInstanceState.get(KEY_SAVED_STATE) != null) {
|
if (savedInstanceState != null && savedInstanceState.get(KEY_SAVED_STATE) != null) {
|
||||||
|
@ -220,7 +226,7 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
playerImpl.setPlaybackQuality(playerState.getPlaybackQuality());
|
playerImpl.setPlaybackQuality(playerState.getPlaybackQuality());
|
||||||
playerImpl.initPlayback(playerState.getPlayQueue(), playerState.getRepeatMode(),
|
playerImpl.initPlayback(playerState.getPlayQueue(), playerState.getRepeatMode(),
|
||||||
playerState.getPlaybackSpeed(), playerState.getPlaybackPitch(),
|
playerState.getPlaybackSpeed(), playerState.getPlaybackPitch(),
|
||||||
playerState.isPlaybackSkipSilence(), playerState.wasPlaying());
|
playerState.isPlaybackSkipSilence(), playerState.wasPlaying(), playerImpl.isMuted());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -248,7 +254,7 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
if (playerImpl == null) return;
|
if (playerImpl == null) return;
|
||||||
|
|
||||||
playerImpl.setRecovery();
|
playerImpl.setRecovery();
|
||||||
if(!playerImpl.gotDestroyed()) {
|
if (!playerImpl.gotDestroyed()) {
|
||||||
playerState = createPlayerState();
|
playerState = createPlayerState();
|
||||||
}
|
}
|
||||||
StateSaver.tryToSave(isChangingConfigurations(), null, outState, this);
|
StateSaver.tryToSave(isChangingConfigurations(), null, outState, this);
|
||||||
|
@ -396,6 +402,12 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
shuffleButton.setImageAlpha(shuffleAlpha);
|
shuffleButton.setImageAlpha(shuffleAlpha);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected void setMuteButton(final ImageButton muteButton, final boolean isMuted) {
|
||||||
|
muteButton.setImageDrawable(AppCompatResources.getDrawable(getApplicationContext(),
|
||||||
|
isMuted ? R.drawable.ic_volume_off_white_72dp : R.drawable.ic_volume_up_white_72dp));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private boolean isInMultiWindow() {
|
private boolean isInMultiWindow() {
|
||||||
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode();
|
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode();
|
||||||
}
|
}
|
||||||
|
@ -448,6 +460,7 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
private ImageButton toggleOrientationButton;
|
private ImageButton toggleOrientationButton;
|
||||||
private ImageButton switchPopupButton;
|
private ImageButton switchPopupButton;
|
||||||
private ImageButton switchBackgroundButton;
|
private ImageButton switchBackgroundButton;
|
||||||
|
private ImageButton muteButton;
|
||||||
|
|
||||||
private RelativeLayout windowRootLayout;
|
private RelativeLayout windowRootLayout;
|
||||||
private View secondaryControls;
|
private View secondaryControls;
|
||||||
|
@ -484,6 +497,7 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
this.shareButton = rootView.findViewById(R.id.share);
|
this.shareButton = rootView.findViewById(R.id.share);
|
||||||
this.toggleOrientationButton = rootView.findViewById(R.id.toggleOrientation);
|
this.toggleOrientationButton = rootView.findViewById(R.id.toggleOrientation);
|
||||||
this.switchBackgroundButton = rootView.findViewById(R.id.switchBackground);
|
this.switchBackgroundButton = rootView.findViewById(R.id.switchBackground);
|
||||||
|
this.muteButton = rootView.findViewById(R.id.switchMute);
|
||||||
this.switchPopupButton = rootView.findViewById(R.id.switchPopup);
|
this.switchPopupButton = rootView.findViewById(R.id.switchPopup);
|
||||||
|
|
||||||
this.queueLayout = findViewById(R.id.playQueuePanel);
|
this.queueLayout = findViewById(R.id.playQueuePanel);
|
||||||
|
@ -493,7 +507,7 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
titleTextView.setSelected(true);
|
titleTextView.setSelected(true);
|
||||||
channelTextView.setSelected(true);
|
channelTextView.setSelected(true);
|
||||||
boolean showKodiButton = PreferenceManager.getDefaultSharedPreferences(this.context).getBoolean(
|
boolean showKodiButton = PreferenceManager.getDefaultSharedPreferences(this.context).getBoolean(
|
||||||
this.context.getString(R.string.show_play_with_kodi_key), false);
|
this.context.getString(R.string.show_play_with_kodi_key), false);
|
||||||
kodiButton.setVisibility(showKodiButton ? View.VISIBLE : View.GONE);
|
kodiButton.setVisibility(showKodiButton ? View.VISIBLE : View.GONE);
|
||||||
|
|
||||||
getRootView().setKeepScreenOn(true);
|
getRootView().setKeepScreenOn(true);
|
||||||
|
@ -535,6 +549,7 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
shareButton.setOnClickListener(this);
|
shareButton.setOnClickListener(this);
|
||||||
toggleOrientationButton.setOnClickListener(this);
|
toggleOrientationButton.setOnClickListener(this);
|
||||||
switchBackgroundButton.setOnClickListener(this);
|
switchBackgroundButton.setOnClickListener(this);
|
||||||
|
muteButton.setOnClickListener(this);
|
||||||
switchPopupButton.setOnClickListener(this);
|
switchPopupButton.setOnClickListener(this);
|
||||||
|
|
||||||
getRootView().addOnLayoutChangeListener((view, l, t, r, b, ol, ot, or, ob) -> {
|
getRootView().addOnLayoutChangeListener((view, l, t, r, b, ol, ot, or, ob) -> {
|
||||||
|
@ -653,7 +668,8 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
this.getPlaybackSkipSilence(),
|
this.getPlaybackSkipSilence(),
|
||||||
this.getPlaybackQuality(),
|
this.getPlaybackQuality(),
|
||||||
false,
|
false,
|
||||||
!isPlaying()
|
!isPlaying(),
|
||||||
|
isMuted()
|
||||||
);
|
);
|
||||||
context.startService(intent);
|
context.startService(intent);
|
||||||
|
|
||||||
|
@ -677,7 +693,8 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
this.getPlaybackSkipSilence(),
|
this.getPlaybackSkipSilence(),
|
||||||
this.getPlaybackQuality(),
|
this.getPlaybackQuality(),
|
||||||
false,
|
false,
|
||||||
!isPlaying()
|
!isPlaying(),
|
||||||
|
isMuted()
|
||||||
);
|
);
|
||||||
context.startService(intent);
|
context.startService(intent);
|
||||||
|
|
||||||
|
@ -686,6 +703,12 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
finish();
|
finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMuteUnmuteButtonClicked() {
|
||||||
|
super.onMuteUnmuteButtonClicked();
|
||||||
|
setMuteButton(muteButton, playerImpl.isMuted());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onClick(View v) {
|
public void onClick(View v) {
|
||||||
|
@ -723,11 +746,14 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
} else if (v.getId() == switchBackgroundButton.getId()) {
|
} else if (v.getId() == switchBackgroundButton.getId()) {
|
||||||
onPlayBackgroundButtonClicked();
|
onPlayBackgroundButtonClicked();
|
||||||
|
|
||||||
|
} else if (v.getId() == muteButton.getId()) {
|
||||||
|
onMuteUnmuteButtonClicked();
|
||||||
|
|
||||||
} else if (v.getId() == closeButton.getId()) {
|
} else if (v.getId() == closeButton.getId()) {
|
||||||
onPlaybackShutdown();
|
onPlaybackShutdown();
|
||||||
return;
|
return;
|
||||||
} else if (v.getId() == kodiButton.getId()) {
|
} else if (v.getId() == kodiButton.getId()) {
|
||||||
onKodiShare();
|
onKodiShare();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getCurrentState() != STATE_COMPLETED) {
|
if (getCurrentState() != STATE_COMPLETED) {
|
||||||
|
@ -770,13 +796,14 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
animateView(secondaryControls, SLIDE_AND_ALPHA, !isMoreControlsVisible,
|
animateView(secondaryControls, SLIDE_AND_ALPHA, !isMoreControlsVisible,
|
||||||
DEFAULT_CONTROLS_DURATION);
|
DEFAULT_CONTROLS_DURATION);
|
||||||
showControls(DEFAULT_CONTROLS_DURATION);
|
showControls(DEFAULT_CONTROLS_DURATION);
|
||||||
|
setMuteButton(muteButton, playerImpl.isMuted());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onShareClicked() {
|
private void onShareClicked() {
|
||||||
// share video at the current time (youtube.com/watch?v=ID&t=SECONDS)
|
// share video at the current time (youtube.com/watch?v=ID&t=SECONDS)
|
||||||
ShareUtils.shareUrl(MainVideoPlayer.this,
|
ShareUtils.shareUrl(MainVideoPlayer.this,
|
||||||
playerImpl.getVideoTitle(),
|
playerImpl.getVideoTitle(),
|
||||||
playerImpl.getVideoUrl() + "&t=" + String.valueOf(playerImpl.getPlaybackSeekBar().getProgress()/1000));
|
playerImpl.getVideoUrl() + "&t=" + String.valueOf(playerImpl.getPlaybackSeekBar().getProgress() / 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onScreenRotationClicked() {
|
private void onScreenRotationClicked() {
|
||||||
|
@ -1009,7 +1036,7 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onSwiped(int index) {
|
public void onSwiped(int index) {
|
||||||
if(index != -1) playQueue.remove(index);
|
if (index != -1) playQueue.remove(index);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1074,6 +1101,10 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
return repeatButton;
|
return repeatButton;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ImageButton getMuteButton() {
|
||||||
|
return muteButton;
|
||||||
|
}
|
||||||
|
|
||||||
public ImageButton getPlayPauseButton() {
|
public ImageButton getPlayPauseButton() {
|
||||||
return playPauseButton;
|
return playPauseButton;
|
||||||
}
|
}
|
||||||
|
@ -1088,7 +1119,8 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onDoubleTap(MotionEvent e) {
|
public boolean onDoubleTap(MotionEvent e) {
|
||||||
if (DEBUG) Log.d(TAG, "onDoubleTap() called with: e = [" + e + "]" + "rawXy = " + e.getRawX() + ", " + e.getRawY() + ", xy = " + e.getX() + ", " + e.getY());
|
if (DEBUG)
|
||||||
|
Log.d(TAG, "onDoubleTap() called with: e = [" + e + "]" + "rawXy = " + e.getRawX() + ", " + e.getRawY() + ", xy = " + e.getX() + ", " + e.getY());
|
||||||
|
|
||||||
if (e.getX() > playerImpl.getRootView().getWidth() * 2 / 3) {
|
if (e.getX() > playerImpl.getRootView().getWidth() * 2 / 3) {
|
||||||
playerImpl.onFastForward();
|
playerImpl.onFastForward();
|
||||||
|
@ -1184,7 +1216,8 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
layoutParams.screenBrightness = currentProgressPercent;
|
layoutParams.screenBrightness = currentProgressPercent;
|
||||||
getWindow().setAttributes(layoutParams);
|
getWindow().setAttributes(layoutParams);
|
||||||
|
|
||||||
if (DEBUG) Log.d(TAG, "onScroll().brightnessControl, currentBrightness = " + currentProgressPercent);
|
if (DEBUG)
|
||||||
|
Log.d(TAG, "onScroll().brightnessControl, currentBrightness = " + currentProgressPercent);
|
||||||
|
|
||||||
final int resId =
|
final int resId =
|
||||||
currentProgressPercent < 0.25 ? R.drawable.ic_brightness_low_white_72dp
|
currentProgressPercent < 0.25 ? R.drawable.ic_brightness_low_white_72dp
|
||||||
|
@ -1223,7 +1256,8 @@ public final class MainVideoPlayer extends AppCompatActivity
|
||||||
@Override
|
@Override
|
||||||
public boolean onTouch(View v, MotionEvent event) {
|
public boolean onTouch(View v, MotionEvent event) {
|
||||||
//noinspection PointlessBooleanExpression
|
//noinspection PointlessBooleanExpression
|
||||||
if (DEBUG && false) Log.d(TAG, "onTouch() called with: v = [" + v + "], event = [" + event + "]");
|
if (DEBUG && false)
|
||||||
|
Log.d(TAG, "onTouch() called with: v = [" + v + "], event = [" + event + "]");
|
||||||
gestureDetector.onTouchEvent(event);
|
gestureDetector.onTouchEvent(event);
|
||||||
if (event.getAction() == MotionEvent.ACTION_UP && isMoving) {
|
if (event.getAction() == MotionEvent.ACTION_UP && isMoving) {
|
||||||
isMoving = false;
|
isMoving = false;
|
||||||
|
|
|
@ -571,7 +571,8 @@ public final class PopupVideoPlayer extends Service {
|
||||||
this.getPlaybackSkipSilence(),
|
this.getPlaybackSkipSilence(),
|
||||||
this.getPlaybackQuality(),
|
this.getPlaybackQuality(),
|
||||||
false,
|
false,
|
||||||
!isPlaying()
|
!isPlaying(),
|
||||||
|
isMuted()
|
||||||
);
|
);
|
||||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
context.startActivity(intent);
|
context.startActivity(intent);
|
||||||
|
@ -607,6 +608,12 @@ public final class PopupVideoPlayer extends Service {
|
||||||
updatePlayback();
|
updatePlayback();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMuteUnmuteButtonClicked() {
|
||||||
|
super.onMuteUnmuteButtonClicked();
|
||||||
|
updatePlayback();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onUpdateProgress(int currentProgress, int duration, int bufferPercent) {
|
public void onUpdateProgress(int currentProgress, int duration, int bufferPercent) {
|
||||||
updateProgress(currentProgress, duration, bufferPercent);
|
updateProgress(currentProgress, duration, bufferPercent);
|
||||||
|
|
|
@ -3,14 +3,17 @@ package org.schabi.newpipe.player;
|
||||||
import android.content.ComponentName;
|
import android.content.ComponentName;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.ServiceConnection;
|
import android.content.ServiceConnection;
|
||||||
|
import android.graphics.drawable.Drawable;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
import androidx.appcompat.widget.Toolbar;
|
import androidx.appcompat.widget.Toolbar;
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||||
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.Menu;
|
import android.view.Menu;
|
||||||
import android.view.MenuItem;
|
import android.view.MenuItem;
|
||||||
|
@ -92,6 +95,8 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
|
||||||
private TextView playbackSpeedButton;
|
private TextView playbackSpeedButton;
|
||||||
private TextView playbackPitchButton;
|
private TextView playbackPitchButton;
|
||||||
|
|
||||||
|
private Menu menu;
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
// Abstracts
|
// Abstracts
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
@ -145,8 +150,10 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onCreateOptionsMenu(Menu menu) {
|
public boolean onCreateOptionsMenu(Menu menu) {
|
||||||
|
this.menu = menu;
|
||||||
getMenuInflater().inflate(R.menu.menu_play_queue, menu);
|
getMenuInflater().inflate(R.menu.menu_play_queue, menu);
|
||||||
getMenuInflater().inflate(getPlayerOptionMenuResource(), menu);
|
getMenuInflater().inflate(getPlayerOptionMenuResource(), menu);
|
||||||
|
onMaybeMuteChanged();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -162,6 +169,9 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
|
||||||
case R.id.action_append_playlist:
|
case R.id.action_append_playlist:
|
||||||
appendAllToPlaylist();
|
appendAllToPlaylist();
|
||||||
return true;
|
return true;
|
||||||
|
case R.id.action_mute:
|
||||||
|
player.onMuteUnmuteButtonClicked();
|
||||||
|
return true;
|
||||||
case R.id.action_system_audio:
|
case R.id.action_system_audio:
|
||||||
startActivity(new Intent(Settings.ACTION_SOUND_SETTINGS));
|
startActivity(new Intent(Settings.ACTION_SOUND_SETTINGS));
|
||||||
return true;
|
return true;
|
||||||
|
@ -169,8 +179,8 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
|
||||||
this.player.setRecovery();
|
this.player.setRecovery();
|
||||||
getApplicationContext().sendBroadcast(getPlayerShutdownIntent());
|
getApplicationContext().sendBroadcast(getPlayerShutdownIntent());
|
||||||
getApplicationContext().startActivity(
|
getApplicationContext().startActivity(
|
||||||
getSwitchIntent(MainVideoPlayer.class)
|
getSwitchIntent(MainVideoPlayer.class)
|
||||||
.putExtra(BasePlayer.START_PAUSED, !this.player.isPlaying())
|
.putExtra(BasePlayer.START_PAUSED, !this.player.isPlaying())
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -194,7 +204,8 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
|
||||||
this.player.getPlaybackSkipSilence(),
|
this.player.getPlaybackSkipSilence(),
|
||||||
null,
|
null,
|
||||||
false,
|
false,
|
||||||
false
|
false,
|
||||||
|
this.player.isMuted()
|
||||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
.putExtra(BasePlayer.START_PAUSED, !this.player.isPlaying());
|
.putExtra(BasePlayer.START_PAUSED, !this.player.isPlaying());
|
||||||
}
|
}
|
||||||
|
@ -212,7 +223,7 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
|
||||||
}
|
}
|
||||||
|
|
||||||
private void unbind() {
|
private void unbind() {
|
||||||
if(serviceBound) {
|
if (serviceBound) {
|
||||||
unbindService(serviceConnection);
|
unbindService(serviceConnection);
|
||||||
serviceBound = false;
|
serviceBound = false;
|
||||||
stopPlayerListener();
|
stopPlayerListener();
|
||||||
|
@ -554,6 +565,7 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
|
||||||
onPlayModeChanged(repeatMode, shuffled);
|
onPlayModeChanged(repeatMode, shuffled);
|
||||||
onPlaybackParameterChanged(parameters);
|
onPlaybackParameterChanged(parameters);
|
||||||
onMaybePlaybackAdapterChanged();
|
onMaybePlaybackAdapterChanged();
|
||||||
|
onMaybeMuteChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -676,4 +688,23 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
|
||||||
itemsList.setAdapter(maybeNewAdapter);
|
itemsList.setAdapter(maybeNewAdapter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void onMaybeMuteChanged() {
|
||||||
|
if (menu != null && player != null) {
|
||||||
|
MenuItem item = menu.findItem(R.id.action_mute);
|
||||||
|
|
||||||
|
//Change the mute-button item in ActionBar
|
||||||
|
//1) Text change:
|
||||||
|
item.setTitle(player.isMuted() ? R.string.unmute : R.string.mute);
|
||||||
|
|
||||||
|
//2) Icon change accordingly to current App Theme
|
||||||
|
item.setIcon(player.isMuted() ? getThemedDrawable(R.attr.volume_off) : getThemedDrawable(R.attr.volume_on));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Drawable getThemedDrawable(int attribute) {
|
||||||
|
return getResources().getDrawable(
|
||||||
|
getTheme().obtainStyledAttributes(R.style.Theme_AppCompat, new int[]{attribute})
|
||||||
|
.getResourceId(0, 0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@ public enum UserAction {
|
||||||
REQUESTED_PLAYLIST("requested playlist"),
|
REQUESTED_PLAYLIST("requested playlist"),
|
||||||
REQUESTED_KIOSK("requested kiosk"),
|
REQUESTED_KIOSK("requested kiosk"),
|
||||||
REQUESTED_COMMENTS("requested comments"),
|
REQUESTED_COMMENTS("requested comments"),
|
||||||
|
REQUESTED_FEED("requested feed"),
|
||||||
DELETE_FROM_HISTORY("delete from history"),
|
DELETE_FROM_HISTORY("delete from history"),
|
||||||
PLAY_STREAM("Play stream"),
|
PLAY_STREAM("Play stream"),
|
||||||
DOWNLOAD_POSTPROCESSING("download post-processing"),
|
DOWNLOAD_POSTPROCESSING("download post-processing"),
|
||||||
|
|
|
@ -17,6 +17,7 @@ import androidx.preference.Preference;
|
||||||
import com.nononsenseapps.filepicker.Utils;
|
import com.nononsenseapps.filepicker.Utils;
|
||||||
import com.nostra13.universalimageloader.core.ImageLoader;
|
import com.nostra13.universalimageloader.core.ImageLoader;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.NewPipeDatabase;
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.extractor.NewPipe;
|
import org.schabi.newpipe.extractor.NewPipe;
|
||||||
import org.schabi.newpipe.extractor.localization.ContentCountry;
|
import org.schabi.newpipe.extractor.localization.ContentCountry;
|
||||||
|
@ -168,6 +169,9 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
|
||||||
|
|
||||||
private void exportDatabase(String path) {
|
private void exportDatabase(String path) {
|
||||||
try {
|
try {
|
||||||
|
//checkpoint before export
|
||||||
|
NewPipeDatabase.checkpoint();
|
||||||
|
|
||||||
ZipOutputStream outZip = new ZipOutputStream(
|
ZipOutputStream outZip = new ZipOutputStream(
|
||||||
new BufferedOutputStream(
|
new BufferedOutputStream(
|
||||||
new FileOutputStream(path)));
|
new FileOutputStream(path)));
|
||||||
|
|
|
@ -23,7 +23,7 @@ package org.schabi.newpipe.settings;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.os.Environment;
|
import android.os.Environment;
|
||||||
import android.preference.PreferenceManager;
|
import androidx.preference.PreferenceManager;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
|
|
|
@ -18,9 +18,9 @@ import com.nostra13.universalimageloader.core.ImageLoader;
|
||||||
|
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||||
|
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||||
import org.schabi.newpipe.report.ErrorActivity;
|
import org.schabi.newpipe.report.ErrorActivity;
|
||||||
import org.schabi.newpipe.report.UserAction;
|
import org.schabi.newpipe.report.UserAction;
|
||||||
import org.schabi.newpipe.local.subscription.SubscriptionService;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Vector;
|
import java.util.Vector;
|
||||||
|
@ -99,8 +99,8 @@ public class SelectChannelFragment extends DialogFragment {
|
||||||
emptyView.setVisibility(View.GONE);
|
emptyView.setVisibility(View.GONE);
|
||||||
|
|
||||||
|
|
||||||
SubscriptionService subscriptionService = SubscriptionService.getInstance(getContext());
|
SubscriptionManager subscriptionManager = new SubscriptionManager(getContext());
|
||||||
subscriptionService.getSubscription().toObservable()
|
subscriptionManager.subscriptions().toObservable()
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(getSubscriptionObserver());
|
.subscribe(getSubscriptionObserver());
|
||||||
|
|
|
@ -6,11 +6,15 @@ import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
|
|
||||||
|
import android.text.format.DateUtils;
|
||||||
|
import android.widget.Toast;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.preference.ListPreference;
|
import androidx.preference.ListPreference;
|
||||||
|
|
||||||
import com.google.android.material.snackbar.Snackbar;
|
import com.google.android.material.snackbar.Snackbar;
|
||||||
|
|
||||||
|
import java.util.LinkedList;
|
||||||
|
import java.util.List;
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.util.PermissionHelper;
|
import org.schabi.newpipe.util.PermissionHelper;
|
||||||
|
|
||||||
|
@ -22,23 +26,7 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment {
|
||||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
//initializing R.array.seek_duration_description to display the translation of seconds
|
updateSeekOptions();
|
||||||
Resources res = getResources();
|
|
||||||
String[] durationsValues = res.getStringArray(R.array.seek_duration_value);
|
|
||||||
String[] durationsDescriptions = res.getStringArray(R.array.seek_duration_description);
|
|
||||||
int currentDurationValue;
|
|
||||||
for (int i = 0; i < durationsDescriptions.length; i++) {
|
|
||||||
currentDurationValue = Integer.parseInt(durationsValues[i]) / 1000;
|
|
||||||
try {
|
|
||||||
durationsDescriptions[i] = String.format(
|
|
||||||
res.getQuantityString(R.plurals.dynamic_seek_duration_description, currentDurationValue),
|
|
||||||
currentDurationValue);
|
|
||||||
} catch (Resources.NotFoundException ignored) {
|
|
||||||
//if this happens, the translation is missing, and the english string will be displayed instead
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ListPreference durations = (ListPreference) findPreference(getString(R.string.seek_duration_key));
|
|
||||||
durations.setEntries(durationsDescriptions);
|
|
||||||
|
|
||||||
listener = (sharedPreferences, s) -> {
|
listener = (sharedPreferences, s) -> {
|
||||||
|
|
||||||
|
@ -58,10 +46,59 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment {
|
||||||
.show();
|
.show();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
} else if (s.equals(getString(R.string.use_inexact_seek_key))) {
|
||||||
|
updateSeekOptions();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update fast-forward/-rewind seek duration options according to language and inexact seek setting.
|
||||||
|
* Exoplayer can't seek 5 seconds in audio when using inexact seek.
|
||||||
|
*/
|
||||||
|
private void updateSeekOptions() {
|
||||||
|
//initializing R.array.seek_duration_description to display the translation of seconds
|
||||||
|
final Resources res = getResources();
|
||||||
|
final String[] durationsValues = res.getStringArray(R.array.seek_duration_value);
|
||||||
|
final List<String> displayedDurationValues = new LinkedList<>();
|
||||||
|
final List<String> displayedDescriptionValues = new LinkedList<>();
|
||||||
|
int currentDurationValue;
|
||||||
|
final boolean inexactSeek = getPreferenceManager().getSharedPreferences()
|
||||||
|
.getBoolean(res.getString(R.string.use_inexact_seek_key), false);
|
||||||
|
|
||||||
|
for (String durationsValue : durationsValues) {
|
||||||
|
currentDurationValue =
|
||||||
|
Integer.parseInt(durationsValue) / (int) DateUtils.SECOND_IN_MILLIS;
|
||||||
|
if (inexactSeek && currentDurationValue % 10 == 5) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
displayedDurationValues.add(durationsValue);
|
||||||
|
try {
|
||||||
|
displayedDescriptionValues.add(String.format(
|
||||||
|
res.getQuantityString(R.plurals.seconds,
|
||||||
|
currentDurationValue),
|
||||||
|
currentDurationValue));
|
||||||
|
} catch (Resources.NotFoundException ignored) {
|
||||||
|
//if this happens, the translation is missing, and the english string will be displayed instead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final ListPreference durations = (ListPreference) findPreference(getString(R.string.seek_duration_key));
|
||||||
|
durations.setEntryValues(displayedDurationValues.toArray(new CharSequence[0]));
|
||||||
|
durations.setEntries(displayedDescriptionValues.toArray(new CharSequence[0]));
|
||||||
|
final int selectedDuration = Integer.parseInt(durations.getValue());
|
||||||
|
if (inexactSeek && selectedDuration / (int) DateUtils.SECOND_IN_MILLIS % 10 == 5) {
|
||||||
|
final int newDuration = selectedDuration / (int) DateUtils.SECOND_IN_MILLIS + 5;
|
||||||
|
durations.setValue(Integer.toString(newDuration * (int) DateUtils.SECOND_IN_MILLIS));
|
||||||
|
|
||||||
|
Toast toast = Toast
|
||||||
|
.makeText(getContext(),
|
||||||
|
getString(R.string.new_seek_duration_toast, newDuration),
|
||||||
|
Toast.LENGTH_LONG);
|
||||||
|
toast.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
|
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
package org.schabi.newpipe.settings.custom
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import androidx.preference.ListPreference
|
||||||
|
import org.schabi.newpipe.util.Localization
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An extension of a common ListPreference where it sets the duration values to human readable strings.
|
||||||
|
*
|
||||||
|
* The values in the entry values array will be interpreted as seconds. If the value of a specific position
|
||||||
|
* is less than or equals to zero, its original entry title will be used.
|
||||||
|
*
|
||||||
|
* If the entry values array have anything other than numbers in it, an exception will be raised.
|
||||||
|
*/
|
||||||
|
class DurationListPreference : ListPreference {
|
||||||
|
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes)
|
||||||
|
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
|
||||||
|
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
|
||||||
|
constructor(context: Context?) : super(context)
|
||||||
|
|
||||||
|
override fun onAttached() {
|
||||||
|
super.onAttached()
|
||||||
|
|
||||||
|
val originalEntryTitles = entries
|
||||||
|
val originalEntryValues = entryValues
|
||||||
|
val newEntryTitles = arrayOfNulls<CharSequence>(originalEntryValues.size)
|
||||||
|
|
||||||
|
for (i in originalEntryValues.indices) {
|
||||||
|
val currentDurationValue: Int
|
||||||
|
try {
|
||||||
|
currentDurationValue = (originalEntryValues[i] as String).toInt()
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
throw RuntimeException("Invalid number was set in the preference entry values array", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentDurationValue <= 0) {
|
||||||
|
newEntryTitles[i] = originalEntryTitles[i]
|
||||||
|
} else {
|
||||||
|
newEntryTitles[i] = Localization.localizeDuration(context, currentDurationValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = newEntryTitles
|
||||||
|
}
|
||||||
|
}
|
|
@ -218,7 +218,7 @@ public abstract class Tab {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getTabName(Context context) {
|
public String getTabName(Context context) {
|
||||||
return context.getString(R.string.fragment_whats_new);
|
return context.getString(R.string.fragment_feed_title);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DrawableRes
|
@DrawableRes
|
||||||
|
|
|
@ -37,6 +37,7 @@ public class WebMReader {
|
||||||
private final static int ID_DefaultDuration = 0x3E383;
|
private final static int ID_DefaultDuration = 0x3E383;
|
||||||
private final static int ID_FlagLacing = 0x1C;
|
private final static int ID_FlagLacing = 0x1C;
|
||||||
private final static int ID_CodecDelay = 0x16AA;
|
private final static int ID_CodecDelay = 0x16AA;
|
||||||
|
private final static int ID_SeekPreRoll = 0x16BB;
|
||||||
|
|
||||||
private final static int ID_Cluster = 0x0F43B675;
|
private final static int ID_Cluster = 0x0F43B675;
|
||||||
private final static int ID_Timecode = 0x67;
|
private final static int ID_Timecode = 0x67;
|
||||||
|
@ -332,6 +333,10 @@ public class WebMReader {
|
||||||
break;
|
break;
|
||||||
case ID_CodecDelay:
|
case ID_CodecDelay:
|
||||||
entry.codecDelay = readNumber(elem);
|
entry.codecDelay = readNumber(elem);
|
||||||
|
break;
|
||||||
|
case ID_SeekPreRoll:
|
||||||
|
entry.seekPreRoll = readNumber(elem);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -414,8 +419,9 @@ public class WebMReader {
|
||||||
public byte[] codecPrivate;
|
public byte[] codecPrivate;
|
||||||
public byte[] bMetadata;
|
public byte[] bMetadata;
|
||||||
public TrackKind kind;
|
public TrackKind kind;
|
||||||
public long defaultDuration;
|
public long defaultDuration = -1;
|
||||||
public long codecDelay;
|
public long codecDelay = -1;
|
||||||
|
public long seekPreRoll = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Segment {
|
public class Segment {
|
||||||
|
|
|
@ -23,7 +23,10 @@ public class WebMWriter implements Closeable {
|
||||||
private final static int BUFFER_SIZE = 8 * 1024;
|
private final static int BUFFER_SIZE = 8 * 1024;
|
||||||
private final static int DEFAULT_TIMECODE_SCALE = 1000000;
|
private final static int DEFAULT_TIMECODE_SCALE = 1000000;
|
||||||
private final static int INTERV = 100;// 100ms on 1000000us timecode scale
|
private final static int INTERV = 100;// 100ms on 1000000us timecode scale
|
||||||
private final static int DEFAULT_CUES_EACH_MS = 5000;// 100ms on 1000000us timecode scale
|
private final static int DEFAULT_CUES_EACH_MS = 5000;// 5000ms on 1000000us timecode scale
|
||||||
|
private final static byte CLUSTER_HEADER_SIZE = 8;
|
||||||
|
private final static int CUE_RESERVE_SIZE = 65535;
|
||||||
|
private final static byte MINIMUM_EBML_VOID_SIZE = 4;
|
||||||
|
|
||||||
private WebMReader.WebMTrack[] infoTracks;
|
private WebMReader.WebMTrack[] infoTracks;
|
||||||
private SharpStream[] sourceTracks;
|
private SharpStream[] sourceTracks;
|
||||||
|
@ -38,15 +41,18 @@ public class WebMWriter implements Closeable {
|
||||||
private Segment[] readersSegment;
|
private Segment[] readersSegment;
|
||||||
private Cluster[] readersCluster;
|
private Cluster[] readersCluster;
|
||||||
|
|
||||||
private int[] predefinedDurations;
|
private ArrayList<ClusterInfo> clustersOffsetsSizes;
|
||||||
|
|
||||||
private byte[] outBuffer;
|
private byte[] outBuffer;
|
||||||
|
private ByteBuffer outByteBuffer;
|
||||||
|
|
||||||
public WebMWriter(SharpStream... source) {
|
public WebMWriter(SharpStream... source) {
|
||||||
sourceTracks = source;
|
sourceTracks = source;
|
||||||
readers = new WebMReader[sourceTracks.length];
|
readers = new WebMReader[sourceTracks.length];
|
||||||
infoTracks = new WebMTrack[sourceTracks.length];
|
infoTracks = new WebMTrack[sourceTracks.length];
|
||||||
outBuffer = new byte[BUFFER_SIZE];
|
outBuffer = new byte[BUFFER_SIZE];
|
||||||
|
outByteBuffer = ByteBuffer.wrap(outBuffer);
|
||||||
|
clustersOffsetsSizes = new ArrayList<>(256);
|
||||||
}
|
}
|
||||||
|
|
||||||
public WebMTrack[] getTracksFromSource(int sourceIndex) throws IllegalStateException {
|
public WebMTrack[] getTracksFromSource(int sourceIndex) throws IllegalStateException {
|
||||||
|
@ -83,11 +89,9 @@ public class WebMWriter implements Closeable {
|
||||||
try {
|
try {
|
||||||
readersSegment = new Segment[readers.length];
|
readersSegment = new Segment[readers.length];
|
||||||
readersCluster = new Cluster[readers.length];
|
readersCluster = new Cluster[readers.length];
|
||||||
predefinedDurations = new int[readers.length];
|
|
||||||
|
|
||||||
for (int i = 0; i < readers.length; i++) {
|
for (int i = 0; i < readers.length; i++) {
|
||||||
infoTracks[i] = readers[i].selectTrack(trackIndex[i]);
|
infoTracks[i] = readers[i].selectTrack(trackIndex[i]);
|
||||||
predefinedDurations[i] = -1;
|
|
||||||
readersSegment[i] = readers[i].getNextSegment();
|
readersSegment[i] = readers[i].getNextSegment();
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -118,6 +122,8 @@ public class WebMWriter implements Closeable {
|
||||||
readersSegment = null;
|
readersSegment = null;
|
||||||
readersCluster = null;
|
readersCluster = null;
|
||||||
outBuffer = null;
|
outBuffer = null;
|
||||||
|
outByteBuffer = null;
|
||||||
|
clustersOffsetsSizes = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void build(SharpStream out) throws IOException, RuntimeException {
|
public void build(SharpStream out) throws IOException, RuntimeException {
|
||||||
|
@ -140,7 +146,7 @@ public class WebMWriter implements Closeable {
|
||||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00// segment content size
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00// segment content size
|
||||||
});
|
});
|
||||||
|
|
||||||
long baseSegmentOffset = written + listBuffer.get(0).length;
|
long segmentOffset = written + listBuffer.get(0).length;
|
||||||
|
|
||||||
/* seek head */
|
/* seek head */
|
||||||
listBuffer.add(new byte[]{
|
listBuffer.add(new byte[]{
|
||||||
|
@ -177,20 +183,22 @@ public class WebMWriter implements Closeable {
|
||||||
/* tracks */
|
/* tracks */
|
||||||
listBuffer.addAll(makeTracks());
|
listBuffer.addAll(makeTracks());
|
||||||
|
|
||||||
for (byte[] buff : listBuffer) {
|
dump(listBuffer, out);
|
||||||
dump(buff, out);
|
|
||||||
}
|
|
||||||
|
|
||||||
// reserve space for Cues element, but is a waste of space (actually is 64 KiB)
|
// reserve space for Cues element
|
||||||
// TODO: better Cue maker
|
long cueOffset = written;
|
||||||
long cueReservedOffset = written;
|
make_EBML_void(out, CUE_RESERVE_SIZE, true);
|
||||||
dump(new byte[]{(byte) 0xec, 0x20, (byte) 0xff, (byte) 0xfb}, out);
|
|
||||||
int reserved = (1024 * 63) - 4;
|
int[] defaultSampleDuration = new int[infoTracks.length];
|
||||||
while (reserved > 0) {
|
long[] duration = new long[infoTracks.length];
|
||||||
int write = Math.min(reserved, outBuffer.length);
|
|
||||||
out.write(outBuffer, 0, write);
|
for (int i = 0; i < infoTracks.length; i++) {
|
||||||
reserved -= write;
|
if (infoTracks[i].defaultDuration < 0) {
|
||||||
written += write;
|
defaultSampleDuration[i] = -1;// not available
|
||||||
|
} else {
|
||||||
|
defaultSampleDuration[i] = (int) Math.ceil(infoTracks[i].defaultDuration / (float) DEFAULT_TIMECODE_SCALE);
|
||||||
|
}
|
||||||
|
duration[i] = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select a track for the cue
|
// Select a track for the cue
|
||||||
|
@ -198,16 +206,8 @@ public class WebMWriter implements Closeable {
|
||||||
long nextCueTime = infoTracks[cuesForTrackId].trackType == 1 ? -1 : 0;
|
long nextCueTime = infoTracks[cuesForTrackId].trackType == 1 ? -1 : 0;
|
||||||
ArrayList<KeyFrame> keyFrames = new ArrayList<>(32);
|
ArrayList<KeyFrame> keyFrames = new ArrayList<>(32);
|
||||||
|
|
||||||
ArrayList<Long> clusterOffsets = new ArrayList<>(32);
|
|
||||||
ArrayList<Integer> clusterSizes = new ArrayList<>(32);
|
|
||||||
|
|
||||||
long duration = 0;
|
|
||||||
int durationFromTrackId = 0;
|
|
||||||
|
|
||||||
byte[] bTimecode = makeTimecode(0);
|
|
||||||
|
|
||||||
int firstClusterOffset = (int) written;
|
int firstClusterOffset = (int) written;
|
||||||
long currentClusterOffset = makeCluster(out, bTimecode, 0, clusterOffsets, clusterSizes);
|
long currentClusterOffset = makeCluster(out, 0, 0, true);
|
||||||
|
|
||||||
long baseTimecode = 0;
|
long baseTimecode = 0;
|
||||||
long limitTimecode = -1;
|
long limitTimecode = -1;
|
||||||
|
@ -239,8 +239,7 @@ public class WebMWriter implements Closeable {
|
||||||
newClusterByTrackId = -1;
|
newClusterByTrackId = -1;
|
||||||
baseTimecode = bloq.absoluteTimecode;
|
baseTimecode = bloq.absoluteTimecode;
|
||||||
limitTimecode = baseTimecode + INTERV;
|
limitTimecode = baseTimecode + INTERV;
|
||||||
bTimecode = makeTimecode(baseTimecode);
|
currentClusterOffset = makeCluster(out, baseTimecode, currentClusterOffset, true);
|
||||||
currentClusterOffset = makeCluster(out, bTimecode, currentClusterOffset, clusterOffsets, clusterSizes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cuesForTrackId == i) {
|
if (cuesForTrackId == i) {
|
||||||
|
@ -248,19 +247,18 @@ public class WebMWriter implements Closeable {
|
||||||
if (nextCueTime > -1) {
|
if (nextCueTime > -1) {
|
||||||
nextCueTime += DEFAULT_CUES_EACH_MS;
|
nextCueTime += DEFAULT_CUES_EACH_MS;
|
||||||
}
|
}
|
||||||
keyFrames.add(
|
keyFrames.add(new KeyFrame(segmentOffset, currentClusterOffset, written, bloq.absoluteTimecode));
|
||||||
new KeyFrame(baseSegmentOffset, currentClusterOffset - 8, written, bTimecode.length, bloq.absoluteTimecode)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
writeBlock(out, bloq, baseTimecode);
|
writeBlock(out, bloq, baseTimecode);
|
||||||
blockWritten++;
|
blockWritten++;
|
||||||
|
|
||||||
if (bloq.absoluteTimecode > duration) {
|
if (defaultSampleDuration[i] < 0 && duration[i] >= 0) {
|
||||||
duration = bloq.absoluteTimecode;
|
// if the sample duration in unknown, calculate using current_duration - previous_duration
|
||||||
durationFromTrackId = bloq.trackNumber;
|
defaultSampleDuration[i] = (int) (bloq.absoluteTimecode - duration[i]);
|
||||||
}
|
}
|
||||||
|
duration[i] = bloq.absoluteTimecode;
|
||||||
|
|
||||||
if (limitTimecode < 0) {
|
if (limitTimecode < 0) {
|
||||||
limitTimecode = bloq.absoluteTimecode + INTERV;
|
limitTimecode = bloq.absoluteTimecode + INTERV;
|
||||||
|
@ -276,55 +274,61 @@ public class WebMWriter implements Closeable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
makeCluster(out, null, currentClusterOffset, null, clusterSizes);
|
makeCluster(out, -1, currentClusterOffset, false);
|
||||||
|
|
||||||
long segmentSize = written - offsetSegmentSizeSet - 7;
|
long segmentSize = written - offsetSegmentSizeSet - 7;
|
||||||
|
|
||||||
/* ---- final step write offsets and sizes ---- */
|
/* Segment size */
|
||||||
seekTo(out, offsetSegmentSizeSet);
|
seekTo(out, offsetSegmentSizeSet);
|
||||||
writeLong(out, segmentSize);
|
outByteBuffer.putLong(0, segmentSize);
|
||||||
|
out.write(outBuffer, 1, DataReader.LONG_SIZE - 1);
|
||||||
|
|
||||||
if (predefinedDurations[durationFromTrackId] > -1) {
|
/* Segment duration */
|
||||||
duration += predefinedDurations[durationFromTrackId];// this value is full-filled in makeTrackEntry() method
|
long longestDuration = 0;
|
||||||
}
|
for (int i = 0; i < duration.length; i++) {
|
||||||
seekTo(out, offsetInfoDurationSet);
|
if (defaultSampleDuration[i] > 0) {
|
||||||
writeFloat(out, duration);
|
duration[i] += defaultSampleDuration[i];
|
||||||
|
}
|
||||||
firstClusterOffset -= baseSegmentOffset;
|
if (duration[i] > longestDuration) {
|
||||||
seekTo(out, offsetClusterSet);
|
longestDuration = duration[i];
|
||||||
writeInt(out, firstClusterOffset);
|
|
||||||
|
|
||||||
seekTo(out, cueReservedOffset);
|
|
||||||
|
|
||||||
/* Cue */
|
|
||||||
dump(new byte[]{0x1c, 0x53, (byte) 0xbb, 0x6b, 0x20, 0x00, 0x00}, out);
|
|
||||||
|
|
||||||
for (KeyFrame keyFrame : keyFrames) {
|
|
||||||
for (byte[] buffer : makeCuePoint(cuesForTrackId, keyFrame)) {
|
|
||||||
dump(buffer, out);
|
|
||||||
if (written >= (cueReservedOffset + 65535 - 16)) {
|
|
||||||
throw new IOException("Too many Cues");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
short cueSize = (short) (written - cueReservedOffset - 7);
|
seekTo(out, offsetInfoDurationSet);
|
||||||
|
outByteBuffer.putFloat(0, longestDuration);
|
||||||
|
dump(outBuffer, DataReader.FLOAT_SIZE, out);
|
||||||
|
|
||||||
/* EBML Void */
|
/* first Cluster offset */
|
||||||
ByteBuffer voidBuffer = ByteBuffer.allocate(4);
|
firstClusterOffset -= segmentOffset;
|
||||||
voidBuffer.putShort((short) 0xec20);
|
writeInt(out, offsetClusterSet, firstClusterOffset);
|
||||||
voidBuffer.putShort((short) (firstClusterOffset - written - 4));
|
|
||||||
dump(voidBuffer.array(), out);
|
|
||||||
|
|
||||||
seekTo(out, offsetCuesSet);
|
seekTo(out, cueOffset);
|
||||||
writeInt(out, (int) (cueReservedOffset - baseSegmentOffset));
|
|
||||||
|
|
||||||
seekTo(out, cueReservedOffset + 5);
|
/* Cue */
|
||||||
writeShort(out, cueSize);
|
short cueSize = 0;
|
||||||
|
dump(new byte[]{0x1c, 0x53, (byte) 0xbb, 0x6b, 0x20, 0x00, 0x00}, out);// header size is 7
|
||||||
|
|
||||||
for (int i = 0; i < clusterSizes.size(); i++) {
|
for (KeyFrame keyFrame : keyFrames) {
|
||||||
seekTo(out, clusterOffsets.get(i));
|
int size = makeCuePoint(cuesForTrackId, keyFrame, outBuffer);
|
||||||
byte[] buffer = ByteBuffer.allocate(4).putInt(clusterSizes.get(i) | 0x10000000).array();
|
|
||||||
dump(buffer, out);
|
if ((cueSize + size + 7 + MINIMUM_EBML_VOID_SIZE) > CUE_RESERVE_SIZE) {
|
||||||
|
break;// no space left
|
||||||
|
}
|
||||||
|
|
||||||
|
cueSize += size;
|
||||||
|
dump(outBuffer, size, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
make_EBML_void(out, CUE_RESERVE_SIZE - cueSize - 7, false);
|
||||||
|
|
||||||
|
seekTo(out, cueOffset + 5);
|
||||||
|
outByteBuffer.putShort(0, cueSize);
|
||||||
|
dump(outBuffer, DataReader.SHORT_SIZE, out);
|
||||||
|
|
||||||
|
/* seek head, seek for cues element */
|
||||||
|
writeInt(out, offsetCuesSet, (int) (cueOffset - segmentOffset));
|
||||||
|
|
||||||
|
for (ClusterInfo cluster : clustersOffsetsSizes) {
|
||||||
|
writeInt(out, cluster.offset, cluster.size | 0x10000000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -375,25 +379,10 @@ public class WebMWriter implements Closeable {
|
||||||
written = offset;
|
written = offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void writeLong(SharpStream stream, long number) throws IOException {
|
private void writeInt(SharpStream stream, long offset, int number) throws IOException {
|
||||||
byte[] buffer = ByteBuffer.allocate(DataReader.LONG_SIZE).putLong(number).array();
|
seekTo(stream, offset);
|
||||||
stream.write(buffer, 1, buffer.length - 1);
|
outByteBuffer.putInt(0, number);
|
||||||
written += buffer.length - 1;
|
dump(outBuffer, DataReader.INTEGER_SIZE, stream);
|
||||||
}
|
|
||||||
|
|
||||||
private void writeFloat(SharpStream stream, float number) throws IOException {
|
|
||||||
byte[] buffer = ByteBuffer.allocate(DataReader.FLOAT_SIZE).putFloat(number).array();
|
|
||||||
dump(buffer, stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void writeShort(SharpStream stream, short number) throws IOException {
|
|
||||||
byte[] buffer = ByteBuffer.allocate(DataReader.SHORT_SIZE).putShort(number).array();
|
|
||||||
dump(buffer, stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void writeInt(SharpStream stream, int number) throws IOException {
|
|
||||||
byte[] buffer = ByteBuffer.allocate(DataReader.INTEGER_SIZE).putInt(number).array();
|
|
||||||
dump(buffer, stream);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void writeBlock(SharpStream stream, Block bloq, long clusterTimecode) throws IOException {
|
private void writeBlock(SharpStream stream, Block bloq, long clusterTimecode) throws IOException {
|
||||||
|
@ -416,47 +405,43 @@ public class WebMWriter implements Closeable {
|
||||||
}
|
}
|
||||||
listBuffer.set(1, encode(blockSize, false));
|
listBuffer.set(1, encode(blockSize, false));
|
||||||
|
|
||||||
for (byte[] buff : listBuffer) {
|
dump(listBuffer, stream);
|
||||||
dump(buff, stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
int read;
|
int read;
|
||||||
while ((read = bloq.data.read(outBuffer)) > 0) {
|
while ((read = bloq.data.read(outBuffer)) > 0) {
|
||||||
stream.write(outBuffer, 0, read);
|
dump(outBuffer, read, stream);
|
||||||
written += read;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] makeTimecode(long timecode) {
|
private long makeCluster(SharpStream stream, long timecode, long offset, boolean create) throws IOException {
|
||||||
ByteBuffer buffer = ByteBuffer.allocate(9);
|
ClusterInfo cluster;
|
||||||
buffer.put((byte) 0xe7);
|
|
||||||
buffer.put(encode(timecode, true));
|
|
||||||
|
|
||||||
byte[] res = new byte[buffer.position()];
|
if (offset > 0) {
|
||||||
System.arraycopy(buffer.array(), 0, res, 0, res.length);
|
// save the size of the previous cluster (maximum 256 MiB)
|
||||||
|
cluster = clustersOffsetsSizes.get(clustersOffsetsSizes.size() - 1);
|
||||||
return res;
|
cluster.size = (int) (written - offset - CLUSTER_HEADER_SIZE);
|
||||||
}
|
|
||||||
|
|
||||||
private long makeCluster(SharpStream stream, byte[] bTimecode, long startOffset, ArrayList<Long> clusterOffsets, ArrayList<Integer> clusterSizes) throws IOException {
|
|
||||||
if (startOffset > 0) {
|
|
||||||
clusterSizes.add((int) (written - startOffset));// size for last offset
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clusterOffsets != null) {
|
offset = written;
|
||||||
|
|
||||||
|
if (create) {
|
||||||
/* cluster */
|
/* cluster */
|
||||||
dump(new byte[]{0x1f, 0x43, (byte) 0xb6, 0x75}, stream);
|
dump(new byte[]{0x1f, 0x43, (byte) 0xb6, 0x75}, stream);
|
||||||
clusterOffsets.add(written);// warning: max cluster size is 256 MiB
|
|
||||||
dump(new byte[]{0x10, 0x00, 0x00, 0x00}, stream);
|
|
||||||
|
|
||||||
startOffset = written;// size for the this cluster
|
cluster = new ClusterInfo();
|
||||||
|
cluster.offset = written;
|
||||||
|
clustersOffsetsSizes.add(cluster);
|
||||||
|
|
||||||
dump(bTimecode, stream);
|
dump(new byte[]{
|
||||||
|
0x10, 0x00, 0x00, 0x00,
|
||||||
|
/* timestamp */
|
||||||
|
(byte) 0xe7
|
||||||
|
}, stream);
|
||||||
|
|
||||||
return startOffset;
|
dump(encode(timecode, true), stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
return -1;
|
return offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void makeEBML(SharpStream stream) throws IOException {
|
private void makeEBML(SharpStream stream) throws IOException {
|
||||||
|
@ -509,13 +494,24 @@ public class WebMWriter implements Closeable {
|
||||||
buffer.add(new byte[]{(byte) 0x86});
|
buffer.add(new byte[]{(byte) 0x86});
|
||||||
buffer.addAll(encode(track.codecId));
|
buffer.addAll(encode(track.codecId));
|
||||||
|
|
||||||
|
/* codec delay*/
|
||||||
|
if (track.codecDelay >= 0) {
|
||||||
|
buffer.add(new byte[]{0x56, (byte) 0xAA});
|
||||||
|
buffer.add(encode(track.codecDelay, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* codec seek pre-roll*/
|
||||||
|
if (track.seekPreRoll >= 0) {
|
||||||
|
buffer.add(new byte[]{0x56, (byte) 0xBB});
|
||||||
|
buffer.add(encode(track.seekPreRoll, true));
|
||||||
|
}
|
||||||
|
|
||||||
/* type */
|
/* type */
|
||||||
buffer.add(new byte[]{(byte) 0x83});
|
buffer.add(new byte[]{(byte) 0x83});
|
||||||
buffer.add(encode(track.trackType, true));
|
buffer.add(encode(track.trackType, true));
|
||||||
|
|
||||||
/* default duration */
|
/* default duration */
|
||||||
if (track.defaultDuration != 0) {
|
if (track.defaultDuration >= 0) {
|
||||||
predefinedDurations[internalTrackId] = (int) Math.ceil(track.defaultDuration / (float) DEFAULT_TIMECODE_SCALE);
|
|
||||||
buffer.add(new byte[]{0x23, (byte) 0xe3, (byte) 0x83});
|
buffer.add(new byte[]{0x23, (byte) 0xe3, (byte) 0x83});
|
||||||
buffer.add(encode(track.defaultDuration, true));
|
buffer.add(encode(track.defaultDuration, true));
|
||||||
}
|
}
|
||||||
|
@ -538,21 +534,29 @@ public class WebMWriter implements Closeable {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ArrayList<byte[]> makeCuePoint(int internalTrackId, KeyFrame keyFrame) {
|
private int makeCuePoint(int internalTrackId, KeyFrame keyFrame, byte[] buffer) {
|
||||||
ArrayList<byte[]> buffer = new ArrayList<>(5);
|
ArrayList<byte[]> cue = new ArrayList<>(5);
|
||||||
|
|
||||||
/* CuePoint */
|
/* CuePoint */
|
||||||
buffer.add(new byte[]{(byte) 0xbb});
|
cue.add(new byte[]{(byte) 0xbb});
|
||||||
buffer.add(null);
|
cue.add(null);
|
||||||
|
|
||||||
/* CueTime */
|
/* CueTime */
|
||||||
buffer.add(new byte[]{(byte) 0xb3});
|
cue.add(new byte[]{(byte) 0xb3});
|
||||||
buffer.add(encode(keyFrame.atTimecode, true));
|
cue.add(encode(keyFrame.duration, true));
|
||||||
|
|
||||||
/* CueTrackPosition */
|
/* CueTrackPosition */
|
||||||
buffer.addAll(makeCueTrackPosition(internalTrackId, keyFrame));
|
cue.addAll(makeCueTrackPosition(internalTrackId, keyFrame));
|
||||||
|
|
||||||
return lengthFor(buffer);
|
int size = 0;
|
||||||
|
lengthFor(cue);
|
||||||
|
|
||||||
|
for (byte[] buff : cue) {
|
||||||
|
System.arraycopy(buff, 0, buffer, size, buff.length);
|
||||||
|
size += buff.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return size;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ArrayList<byte[]> makeCueTrackPosition(int internalTrackId, KeyFrame keyFrame) {
|
private ArrayList<byte[]> makeCueTrackPosition(int internalTrackId, KeyFrame keyFrame) {
|
||||||
|
@ -568,20 +572,48 @@ public class WebMWriter implements Closeable {
|
||||||
|
|
||||||
/* CueClusterPosition */
|
/* CueClusterPosition */
|
||||||
buffer.add(new byte[]{(byte) 0xf1});
|
buffer.add(new byte[]{(byte) 0xf1});
|
||||||
buffer.add(encode(keyFrame.atCluster, true));
|
buffer.add(encode(keyFrame.clusterPosition, true));
|
||||||
|
|
||||||
/* CueRelativePosition */
|
/* CueRelativePosition */
|
||||||
if (keyFrame.atBlock > 0) {
|
if (keyFrame.relativePosition > 0) {
|
||||||
buffer.add(new byte[]{(byte) 0xf0});
|
buffer.add(new byte[]{(byte) 0xf0});
|
||||||
buffer.add(encode(keyFrame.atBlock, true));
|
buffer.add(encode(keyFrame.relativePosition, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
return lengthFor(buffer);
|
return lengthFor(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void make_EBML_void(SharpStream out, int size, boolean wipe) throws IOException {
|
||||||
|
/* ebml void */
|
||||||
|
outByteBuffer.putShort(0, (short) 0xec20);
|
||||||
|
outByteBuffer.putShort(2, (short) (size - 4));
|
||||||
|
|
||||||
|
dump(outBuffer, 4, out);
|
||||||
|
|
||||||
|
if (wipe) {
|
||||||
|
size -= 4;
|
||||||
|
while (size > 0) {
|
||||||
|
int write = Math.min(size, outBuffer.length);
|
||||||
|
dump(outBuffer, write, out);
|
||||||
|
size -= write;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void dump(byte[] buffer, SharpStream stream) throws IOException {
|
private void dump(byte[] buffer, SharpStream stream) throws IOException {
|
||||||
stream.write(buffer);
|
dump(buffer, buffer.length, stream);
|
||||||
written += buffer.length;
|
}
|
||||||
|
|
||||||
|
private void dump(byte[] buffer, int count, SharpStream stream) throws IOException {
|
||||||
|
stream.write(buffer, 0, count);
|
||||||
|
written += count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dump(ArrayList<byte[]> buffers, SharpStream stream) throws IOException {
|
||||||
|
for (byte[] buffer : buffers) {
|
||||||
|
stream.write(buffer);
|
||||||
|
written += buffer.length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ArrayList<byte[]> lengthFor(ArrayList<byte[]> buffer) {
|
private ArrayList<byte[]> lengthFor(ArrayList<byte[]> buffer) {
|
||||||
|
@ -614,11 +646,11 @@ public class WebMWriter implements Closeable {
|
||||||
byte[] buffer = new byte[offset + length];
|
byte[] buffer = new byte[offset + length];
|
||||||
long marker = (long) Math.floor((length - 1f) / 8f);
|
long marker = (long) Math.floor((length - 1f) / 8f);
|
||||||
|
|
||||||
float mul = 1;
|
int shift = 0;
|
||||||
for (int i = length - 1; i >= 0; i--, mul *= 0x100) {
|
for (int i = length - 1; i >= 0; i--, shift += 8) {
|
||||||
long b = (long) Math.floor(number / mul);
|
long b = number >>> shift;
|
||||||
if (!withLength && i == marker) {
|
if (!withLength && i == marker) {
|
||||||
b = b | (0x80 >> (length - 1));
|
b = b | (0x80 >>> (length - 1));
|
||||||
}
|
}
|
||||||
buffer[offset + i] = (byte) b;
|
buffer[offset + i] = (byte) b;
|
||||||
}
|
}
|
||||||
|
@ -686,17 +718,15 @@ public class WebMWriter implements Closeable {
|
||||||
|
|
||||||
class KeyFrame {
|
class KeyFrame {
|
||||||
|
|
||||||
KeyFrame(long segment, long cluster, long block, int bTimecodeLength, long timecode) {
|
KeyFrame(long segment, long cluster, long block, long timecode) {
|
||||||
atCluster = cluster - segment;
|
clusterPosition = cluster - segment;
|
||||||
if ((block - bTimecodeLength) > cluster) {
|
relativePosition = (int) (block - cluster - CLUSTER_HEADER_SIZE);
|
||||||
atBlock = (int) (block - cluster);
|
duration = timecode;
|
||||||
}
|
|
||||||
atTimecode = timecode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
long atCluster;
|
final long clusterPosition;
|
||||||
int atBlock;
|
final int relativePosition;
|
||||||
long atTimecode;
|
final long duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
class Block {
|
class Block {
|
||||||
|
@ -717,4 +747,11 @@ public class WebMWriter implements Closeable {
|
||||||
return String.format("trackNumber=%s isKeyFrame=%S absoluteTimecode=%s", trackNumber, isKeyframe(), absoluteTimecode);
|
return String.format("trackNumber=%s isKeyFrame=%S absoluteTimecode=%s", trackNumber, isKeyframe(), absoluteTimecode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ClusterInfo {
|
||||||
|
|
||||||
|
long offset;
|
||||||
|
int size;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
6
app/src/main/java/org/schabi/newpipe/util/ConstantsKt.kt
Normal file
6
app/src/main/java/org/schabi/newpipe/util/ConstantsKt.kt
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
package org.schabi.newpipe.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default duration when using throttle functions across the app, in milliseconds.
|
||||||
|
*/
|
||||||
|
const val DEFAULT_THROTTLE_TIMEOUT = 120L
|
|
@ -31,18 +31,23 @@ import org.schabi.newpipe.ReCaptchaActivity;
|
||||||
import org.schabi.newpipe.extractor.Info;
|
import org.schabi.newpipe.extractor.Info;
|
||||||
import org.schabi.newpipe.extractor.InfoItem;
|
import org.schabi.newpipe.extractor.InfoItem;
|
||||||
import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage;
|
import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage;
|
||||||
|
import org.schabi.newpipe.extractor.ListInfo;
|
||||||
import org.schabi.newpipe.extractor.NewPipe;
|
import org.schabi.newpipe.extractor.NewPipe;
|
||||||
import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor;
|
import org.schabi.newpipe.extractor.StreamingService;
|
||||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||||
import org.schabi.newpipe.extractor.comments.CommentsInfo;
|
import org.schabi.newpipe.extractor.comments.CommentsInfo;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
|
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
|
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
|
||||||
|
import org.schabi.newpipe.extractor.feed.FeedExtractor;
|
||||||
|
import org.schabi.newpipe.extractor.feed.FeedInfo;
|
||||||
import org.schabi.newpipe.extractor.kiosk.KioskInfo;
|
import org.schabi.newpipe.extractor.kiosk.KioskInfo;
|
||||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
|
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
|
||||||
import org.schabi.newpipe.extractor.search.SearchInfo;
|
import org.schabi.newpipe.extractor.search.SearchInfo;
|
||||||
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor;
|
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||||
|
import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor;
|
||||||
import org.schabi.newpipe.report.ErrorActivity;
|
import org.schabi.newpipe.report.ErrorActivity;
|
||||||
import org.schabi.newpipe.report.UserAction;
|
import org.schabi.newpipe.report.UserAction;
|
||||||
|
|
||||||
|
@ -131,6 +136,22 @@ public final class ExtractorHelper {
|
||||||
ChannelInfo.getMoreItems(NewPipe.getService(serviceId), url, nextStreamsUrl));
|
ChannelInfo.getMoreItems(NewPipe.getService(serviceId), url, nextStreamsUrl));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Single<ListInfo<StreamInfoItem>> getFeedInfoFallbackToChannelInfo(final int serviceId,
|
||||||
|
final String url) {
|
||||||
|
final Maybe<ListInfo<StreamInfoItem>> maybeFeedInfo = Maybe.fromCallable(() -> {
|
||||||
|
final StreamingService service = NewPipe.getService(serviceId);
|
||||||
|
final FeedExtractor feedExtractor = service.getFeedExtractor(url);
|
||||||
|
|
||||||
|
if (feedExtractor == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FeedInfo.getInfo(feedExtractor);
|
||||||
|
});
|
||||||
|
|
||||||
|
return maybeFeedInfo.switchIfEmpty(getChannelInfo(serviceId, url, true));
|
||||||
|
}
|
||||||
|
|
||||||
public static Single<CommentsInfo> getCommentsInfo(final int serviceId,
|
public static Single<CommentsInfo> getCommentsInfo(final int serviceId,
|
||||||
final String url,
|
final String url,
|
||||||
boolean forceLoad) {
|
boolean forceLoad) {
|
||||||
|
|
|
@ -213,6 +213,42 @@ public class Localization {
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Localize an amount of seconds into a human readable string.
|
||||||
|
*
|
||||||
|
* <p>The seconds will be converted to the closest whole time unit.
|
||||||
|
* <p>For example, 60 seconds would give "1 minute", 119 would also give "1 minute".
|
||||||
|
*
|
||||||
|
* @param context used to get plurals resources.
|
||||||
|
* @param durationInSecs an amount of seconds.
|
||||||
|
* @return duration in a human readable string.
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public static String localizeDuration(Context context, int durationInSecs) {
|
||||||
|
if (durationInSecs < 0) {
|
||||||
|
throw new IllegalArgumentException("duration can not be negative");
|
||||||
|
}
|
||||||
|
|
||||||
|
final int days = (int) (durationInSecs / (24 * 60 * 60L)); /* greater than a day */
|
||||||
|
durationInSecs %= (24 * 60 * 60L);
|
||||||
|
final int hours = (int) (durationInSecs / (60 * 60L)); /* greater than an hour */
|
||||||
|
durationInSecs %= (60 * 60L);
|
||||||
|
final int minutes = (int) (durationInSecs / 60L);
|
||||||
|
final int seconds = (int) (durationInSecs % 60L);
|
||||||
|
|
||||||
|
final Resources resources = context.getResources();
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
return resources.getQuantityString(R.plurals.days, days, days);
|
||||||
|
} else if (hours > 0) {
|
||||||
|
return resources.getQuantityString(R.plurals.hours, hours, hours);
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return resources.getQuantityString(R.plurals.minutes, minutes, minutes);
|
||||||
|
} else {
|
||||||
|
return resources.getQuantityString(R.plurals.seconds, seconds, seconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
// Pretty Time
|
// Pretty Time
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
|
@ -23,6 +23,7 @@ import org.schabi.newpipe.MainActivity;
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.RouterActivity;
|
import org.schabi.newpipe.RouterActivity;
|
||||||
import org.schabi.newpipe.about.AboutActivity;
|
import org.schabi.newpipe.about.AboutActivity;
|
||||||
|
import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
|
||||||
import org.schabi.newpipe.download.DownloadActivity;
|
import org.schabi.newpipe.download.DownloadActivity;
|
||||||
import org.schabi.newpipe.extractor.NewPipe;
|
import org.schabi.newpipe.extractor.NewPipe;
|
||||||
import org.schabi.newpipe.extractor.StreamingService;
|
import org.schabi.newpipe.extractor.StreamingService;
|
||||||
|
@ -110,13 +111,15 @@ public class NavigationHelper {
|
||||||
final boolean playbackSkipSilence,
|
final boolean playbackSkipSilence,
|
||||||
@Nullable final String playbackQuality,
|
@Nullable final String playbackQuality,
|
||||||
final boolean resumePlayback,
|
final boolean resumePlayback,
|
||||||
final boolean startPaused) {
|
final boolean startPaused,
|
||||||
|
final boolean isMuted) {
|
||||||
return getPlayerIntent(context, targetClazz, playQueue, playbackQuality, resumePlayback)
|
return getPlayerIntent(context, targetClazz, playQueue, playbackQuality, resumePlayback)
|
||||||
.putExtra(BasePlayer.REPEAT_MODE, repeatMode)
|
.putExtra(BasePlayer.REPEAT_MODE, repeatMode)
|
||||||
.putExtra(BasePlayer.PLAYBACK_SPEED, playbackSpeed)
|
.putExtra(BasePlayer.PLAYBACK_SPEED, playbackSpeed)
|
||||||
.putExtra(BasePlayer.PLAYBACK_PITCH, playbackPitch)
|
.putExtra(BasePlayer.PLAYBACK_PITCH, playbackPitch)
|
||||||
.putExtra(BasePlayer.PLAYBACK_SKIP_SILENCE, playbackSkipSilence)
|
.putExtra(BasePlayer.PLAYBACK_SKIP_SILENCE, playbackSkipSilence)
|
||||||
.putExtra(BasePlayer.START_PAUSED, startPaused);
|
.putExtra(BasePlayer.START_PAUSED, startPaused)
|
||||||
|
.putExtra(BasePlayer.IS_MUTED, isMuted);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void playOnMainPlayer(final Context context, final PlayQueue queue, final boolean resumePlayback) {
|
public static void playOnMainPlayer(final Context context, final PlayQueue queue, final boolean resumePlayback) {
|
||||||
|
@ -341,9 +344,13 @@ public class NavigationHelper {
|
||||||
.commit();
|
.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void openWhatsNewFragment(FragmentManager fragmentManager) {
|
public static void openFeedFragment(FragmentManager fragmentManager) {
|
||||||
|
openFeedFragment(fragmentManager, FeedGroupEntity.GROUP_ALL_ID, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void openFeedFragment(FragmentManager fragmentManager, long groupId, @Nullable String groupName) {
|
||||||
defaultTransaction(fragmentManager)
|
defaultTransaction(fragmentManager)
|
||||||
.replace(R.id.fragment_holder, new FeedFragment())
|
.replace(R.id.fragment_holder, FeedFragment.newInstance(groupId, groupName))
|
||||||
.addToBackStack(null)
|
.addToBackStack(null)
|
||||||
.commit();
|
.commit();
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue