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.
|
||||
|
|
|
@ -9,13 +9,20 @@ android {
|
|||
|
||||
defaultConfig {
|
||||
applicationId "org.schabi.newpipe"
|
||||
resValue "string", "app_name", "NewPipe"
|
||||
minSdkVersion 19
|
||||
targetSdkVersion 28
|
||||
versionCode 870
|
||||
versionName "0.18.7"
|
||||
versionCode 900
|
||||
versionName "0.19.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
javaCompileOptions {
|
||||
annotationProcessorOptions {
|
||||
arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
@ -28,7 +35,18 @@ android {
|
|||
debug {
|
||||
multiDexEnabled true
|
||||
debuggable true
|
||||
|
||||
// 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
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
// Required and used only by groupie
|
||||
androidExtensions {
|
||||
experimental = true
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||
}
|
||||
}
|
||||
|
||||
ext {
|
||||
|
@ -59,11 +86,13 @@ ext {
|
|||
dependencies {
|
||||
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', {
|
||||
exclude module: 'support-annotations'
|
||||
})
|
||||
|
||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:6f03c6e87'
|
||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:69e0624e3'
|
||||
testImplementation 'junit:junit:4.12'
|
||||
testImplementation 'org.mockito:mockito-core:2.23.0'
|
||||
|
||||
|
@ -75,6 +104,13 @@ dependencies {
|
|||
implementation "androidx.cardview:cardview:${androidxLibVersion}"
|
||||
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
|
||||
implementation 'com.grack:nanojson:1.1'
|
||||
implementation 'org.jsoup:jsoup:1.9.2'
|
||||
|
@ -113,3 +149,19 @@ dependencies {
|
|||
implementation "io.noties.markwon:core:${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
|
||||
android:name=".DebugApp"
|
||||
android:label="NewPipe Debug"
|
||||
tools:replace="android:name, android:label">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:label="NewPipe Debug"
|
||||
tools:replace="android:label"/>
|
||||
</application>
|
||||
|
||||
tools:replace="android:name" />
|
||||
</manifest>
|
|
@ -74,6 +74,7 @@
|
|||
|
||||
<service android:name=".local.subscription.services.SubscriptionsImportService"/>
|
||||
<service android:name=".local.subscription.services.SubscriptionsExportService"/>
|
||||
<service android:name=".local.feed.service.FeedLoadService"/>
|
||||
|
||||
<activity
|
||||
android:name=".PanicResponderActivity"
|
||||
|
@ -146,6 +147,7 @@
|
|||
<data android:host="youtube.com"/>
|
||||
<data android:host="m.youtube.com"/>
|
||||
<data android:host="www.youtube.com"/>
|
||||
<data android:host="music.youtube.com"/>
|
||||
<!-- video prefix -->
|
||||
<data android:pathPrefix="/v/"/>
|
||||
<data android:pathPrefix="/embed/"/>
|
||||
|
@ -244,14 +246,7 @@
|
|||
<data android:host="tube.poal.co"/>
|
||||
<data android:host="invidious.13ad.de"/>
|
||||
<data android:host="yt.elukerio.org"/>
|
||||
<!-- video prefix -->
|
||||
<data android:pathPrefix="/embed/"/>
|
||||
<data android:pathPrefix="/watch"/>
|
||||
<!-- channel prefix -->
|
||||
<data android:pathPrefix="/channel/"/>
|
||||
<data android:pathPrefix="/user/"/>
|
||||
<!-- playlist prefix -->
|
||||
<data android:pathPrefix="/playlist"/>
|
||||
<data android:pathPrefix="/"/>
|
||||
</intent-filter>
|
||||
|
||||
<!-- Soundcloud filter -->
|
||||
|
@ -277,8 +272,26 @@
|
|||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</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
|
||||
android:name=".RouterActivity$FetcherService"
|
||||
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)
|
||||
.setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_channel));
|
||||
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));
|
||||
drawerItems.getMenu()
|
||||
.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());
|
||||
break;
|
||||
case ITEM_ID_FEED:
|
||||
NavigationHelper.openWhatsNewFragment(getSupportFragmentManager());
|
||||
NavigationHelper.openFeedFragment(getSupportFragmentManager());
|
||||
break;
|
||||
case ITEM_ID_BOOKMARKS:
|
||||
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)
|
||||
.setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_channel));
|
||||
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));
|
||||
drawerItems.getMenu()
|
||||
.add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks)
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
package org.schabi.newpipe;
|
||||
|
||||
import androidx.room.Room;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.room.Room;
|
||||
|
||||
import org.schabi.newpipe.database.AppDatabase;
|
||||
|
||||
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_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 {
|
||||
|
||||
|
@ -20,8 +23,7 @@ public final class NewPipeDatabase {
|
|||
private static AppDatabase getDatabase(Context context) {
|
||||
return Room
|
||||
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
|
||||
.addMigrations(MIGRATION_11_12)
|
||||
.fallbackToDestructiveMigration()
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
@ -39,4 +41,14 @@ public final class NewPipeDatabase {
|
|||
|
||||
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.os.Bundle;
|
||||
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.view.ContextThemeWrapper;
|
||||
import android.view.LayoutInflater;
|
||||
|
@ -26,6 +20,12 @@ import android.widget.RadioButton;
|
|||
import android.widget.RadioGroup;
|
||||
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 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.PermissionHelper;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.util.urlfinder.UrlFinder;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
import icepick.Icepick;
|
||||
|
@ -625,78 +624,18 @@ public class RouterActivity extends AppCompatActivity {
|
|||
// Utils
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
/**
|
||||
* 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}]";
|
||||
|
||||
@Nullable
|
||||
private String getUrl(Intent intent) {
|
||||
// first gather data and find service
|
||||
String videoUrl = null;
|
||||
String foundUrl = null;
|
||||
if (intent.getData() != null) {
|
||||
// this means the video was called though another app
|
||||
videoUrl = intent.getData().toString();
|
||||
// Called from another app
|
||||
foundUrl = intent.getData().toString();
|
||||
} else if (intent.getStringExtra(Intent.EXTRA_TEXT) != null) {
|
||||
//this means that vidoe was called through share menu
|
||||
String extraText = intent.getStringExtra(Intent.EXTRA_TEXT);
|
||||
final String[] uris = getUris(extraText);
|
||||
videoUrl = uris.length > 0 ? uris[0] : null;
|
||||
// Called from the share menu
|
||||
final String extraText = intent.getStringExtra(Intent.EXTRA_TEXT);
|
||||
foundUrl = UrlFinder.firstUrlFromInput(extraText);
|
||||
}
|
||||
|
||||
return videoUrl;
|
||||
}
|
||||
|
||||
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()]);
|
||||
return foundUrl;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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("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("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.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.StreamHistoryDAO;
|
||||
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.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})
|
||||
@Database(
|
||||
entities = {
|
||||
SubscriptionEntity.class, SearchHistoryEntry.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,
|
||||
exportSchema = false
|
||||
version = DB_VER_3
|
||||
)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
|
||||
public static final String DATABASE_NAME = "newpipe.db";
|
||||
|
||||
public abstract SubscriptionDAO subscriptionDAO();
|
||||
|
||||
public abstract SearchHistoryDAO searchHistoryDAO();
|
||||
|
||||
public abstract StreamDAO streamDAO();
|
||||
|
||||
public abstract StreamHistoryDAO streamHistoryDAO();
|
||||
|
||||
public abstract StreamStateDAO streamStateDAO();
|
||||
|
||||
public abstract PlaylistDAO playlistDAO();
|
||||
|
||||
public abstract PlaylistStreamDAO playlistStreamDAO();
|
||||
|
||||
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 org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.local.subscription.FeedGroupIcon;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
|
@ -37,4 +38,18 @@ public class Converters {
|
|||
public static String stringOf(StreamType streamType) {
|
||||
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;
|
||||
|
||||
public class Migrations {
|
||||
|
||||
public static final int DB_VER_11_0 = 1;
|
||||
public static final int DB_VER_12_0 = 2;
|
||||
public static final int DB_VER_1 = 1;
|
||||
public static final int DB_VER_2 = 2;
|
||||
public static final int DB_VER_3 = 3;
|
||||
|
||||
public static final boolean DEBUG = !BuildConfig.BUILD_TYPE.equals("release");
|
||||
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
|
||||
public void migrate(@NonNull SupportSQLiteDatabase database) {
|
||||
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)})
|
||||
public class SubscriptionEntity {
|
||||
|
||||
final static String SUBSCRIPTION_UID = "uid";
|
||||
final static String SUBSCRIPTION_TABLE = "subscriptions";
|
||||
final static String SUBSCRIPTION_SERVICE_ID = "service_id";
|
||||
final static String SUBSCRIPTION_URL = "url";
|
||||
final static String SUBSCRIPTION_NAME = "name";
|
||||
final static String SUBSCRIPTION_AVATAR_URL = "avatar_url";
|
||||
final static String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
|
||||
final static String SUBSCRIPTION_DESCRIPTION = "description";
|
||||
public static final String SUBSCRIPTION_UID = "uid";
|
||||
public static final String SUBSCRIPTION_TABLE = "subscriptions";
|
||||
public static final String SUBSCRIPTION_SERVICE_ID = "service_id";
|
||||
public static final String SUBSCRIPTION_URL = "url";
|
||||
public static final String SUBSCRIPTION_NAME = "name";
|
||||
public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url";
|
||||
public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
|
||||
public static final String SUBSCRIPTION_DESCRIPTION = "description";
|
||||
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
private long uid = 0;
|
||||
|
|
|
@ -17,6 +17,7 @@ import org.schabi.newpipe.BaseFragment;
|
|||
import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.ReCaptchaActivity;
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
|
||||
import org.schabi.newpipe.report.ErrorActivity;
|
||||
import org.schabi.newpipe.report.UserAction;
|
||||
|
@ -181,6 +182,9 @@ public abstract class BaseStateFragment<I> extends BaseFragment implements ViewC
|
|||
if (exception instanceof ReCaptchaException) {
|
||||
onReCaptchaException((ReCaptchaException) exception);
|
||||
return true;
|
||||
} else if (exception instanceof ContentNotAvailableException) {
|
||||
showError(getString(R.string.content_not_available), false);
|
||||
return true;
|
||||
} else if (exception instanceof IOException) {
|
||||
showError(getString(R.string.network_error), true);
|
||||
return true;
|
||||
|
|
|
@ -16,7 +16,7 @@ import androidx.appcompat.app.ActionBar;
|
|||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.fragment.app.FragmentStatePagerAdapter;
|
||||
import androidx.fragment.app.FragmentStatePagerAdapterMenuWorkaround;
|
||||
import androidx.viewpager.widget.ViewPager;
|
||||
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
|
@ -136,16 +136,17 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
|||
// Tabs
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
public void setupTabs() {
|
||||
private void setupTabs() {
|
||||
tabsList.clear();
|
||||
tabsList.addAll(tabsManager.getTabs());
|
||||
|
||||
if (pagerAdapter == null || !pagerAdapter.sameTabs(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.setAdapter(pagerAdapter);
|
||||
|
||||
updateTabsIconAndDescription();
|
||||
updateTitleForTab(viewPager.getCurrentItem());
|
||||
|
@ -184,7 +185,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
|||
updateTitleForTab(tab.getPosition());
|
||||
}
|
||||
|
||||
private static class SelectedTabsPagerAdapter extends FragmentStatePagerAdapter {
|
||||
private static class SelectedTabsPagerAdapter extends FragmentStatePagerAdapterMenuWorkaround {
|
||||
private final Context context;
|
||||
private final List<Tab> internalTabsList;
|
||||
|
||||
|
@ -194,6 +195,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
|||
this.internalTabsList = new ArrayList<>(tabsList);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Fragment getItem(int 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.NewPipe;
|
||||
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.ParsingException;
|
||||
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor;
|
||||
|
@ -1220,20 +1219,12 @@ public class VideoDetailFragment
|
|||
protected boolean onError(Throwable exception) {
|
||||
if (super.onError(exception)) return true;
|
||||
|
||||
else if (exception instanceof ContentNotAvailableException) {
|
||||
showError(getString(R.string.content_not_available), false);
|
||||
} else {
|
||||
int errorId = exception instanceof YoutubeStreamExtractor.DecryptException
|
||||
? R.string.youtube_signature_decryption_error
|
||||
: exception instanceof ParsingException
|
||||
? R.string.parsing_error
|
||||
int errorId = exception instanceof YoutubeStreamExtractor.DecryptException ? R.string.youtube_signature_decryption_error
|
||||
: exception instanceof ExtractionException ? R.string.parsing_error
|
||||
: R.string.general_error;
|
||||
onUnrecoverableError(exception,
|
||||
UserAction.REQUESTED_STREAM,
|
||||
NewPipe.getNameOfService(serviceId),
|
||||
url,
|
||||
errorId);
|
||||
}
|
||||
|
||||
onUnrecoverableError(exception, UserAction.REQUESTED_STREAM,
|
||||
NewPipe.getNameOfService(serviceId), url, errorId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
@ -1246,12 +1237,22 @@ public class VideoDetailFragment
|
|||
final boolean playbackResumeEnabled =
|
||||
prefs.getBoolean(activity.getString(R.string.enable_watch_history_key), true)
|
||||
&& prefs.getBoolean(activity.getString(R.string.enable_playback_resume_key), true);
|
||||
|
||||
if (!playbackResumeEnabled || info.getDuration() <= 0) {
|
||||
positionView.setVisibility(View.INVISIBLE);
|
||||
detailPositionView.setVisibility(View.GONE);
|
||||
|
||||
// TODO: Remove this check when separation of concerns is done.
|
||||
// (live streams weren't getting updated because they are mixed)
|
||||
if (!info.getStreamType().equals(StreamType.LIVE_STREAM) &&
|
||||
!info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
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)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.onErrorComplete()
|
||||
|
|
|
@ -59,8 +59,11 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
|||
@Override
|
||||
public void onAttach(Context context) {
|
||||
super.onAttach(context);
|
||||
|
||||
if (infoListAdapter == null) {
|
||||
infoListAdapter = new InfoListAdapter(activity);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDetach() {
|
||||
|
@ -78,7 +81,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
|||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
StateSaver.onDestroy(savedState);
|
||||
if (useDefaultStateSaving) StateSaver.onDestroy(savedState);
|
||||
PreferenceManager.getDefaultSharedPreferences(activity)
|
||||
.unregisterOnSharedPreferenceChangeListener(this);
|
||||
}
|
||||
|
@ -103,6 +106,16 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
|||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
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
|
||||
public String generateSuffix() {
|
||||
|
@ -112,26 +125,28 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I> implem
|
|||
|
||||
@Override
|
||||
public void writeTo(Queue<Object> objectsToSave) {
|
||||
objectsToSave.add(infoListAdapter.getItemsList());
|
||||
if (useDefaultStateSaving) objectsToSave.add(infoListAdapter.getItemsList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public void readFrom(@NonNull Queue<Object> savedObjects) throws Exception {
|
||||
if (useDefaultStateSaving) {
|
||||
infoListAdapter.getItemsList().clear();
|
||||
infoListAdapter.getItemsList().addAll((List<InfoItem>) savedObjects.poll());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(Bundle bundle) {
|
||||
super.onSaveInstanceState(bundle);
|
||||
savedState = StateSaver.tryToSave(activity.isChangingConfigurations(), savedState, bundle, this);
|
||||
if (useDefaultStateSaving) savedState = StateSaver.tryToSave(activity.isChangingConfigurations(), savedState, bundle, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRestoreInstanceState(@NonNull Bundle 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.stream.StreamInfoItem;
|
||||
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.PlayQueue;
|
||||
import org.schabi.newpipe.report.UserAction;
|
||||
|
@ -66,7 +66,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
|||
|
||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||
private Disposable subscribeButtonMonitor;
|
||||
private SubscriptionService subscriptionService;
|
||||
private SubscriptionManager subscriptionManager;
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Views
|
||||
|
@ -109,7 +109,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
|||
@Override
|
||||
public void onAttach(Context context) {
|
||||
super.onAttach(context);
|
||||
subscriptionService = SubscriptionService.getInstance(activity);
|
||||
subscriptionManager = new SubscriptionManager(activity);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -212,8 +212,8 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
|||
0);
|
||||
};
|
||||
|
||||
final Observable<List<SubscriptionEntity>> observable = subscriptionService.subscriptionTable()
|
||||
.getSubscription(info.getServiceId(), info.getUrl())
|
||||
final Observable<List<SubscriptionEntity>> observable = subscriptionManager.subscriptionTable()
|
||||
.getSubscriptionFlowable(info.getServiceId(), info.getUrl())
|
||||
.toObservable();
|
||||
|
||||
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) -> {
|
||||
subscriptionService.subscriptionTable().insert(subscription);
|
||||
subscriptionManager.insertSubscription(subscription, info);
|
||||
return o;
|
||||
};
|
||||
}
|
||||
|
||||
private Function<Object, Object> mapOnUnsubscribe(final SubscriptionEntity subscription) {
|
||||
return (@NonNull Object o) -> {
|
||||
subscriptionService.subscriptionTable().delete(subscription);
|
||||
subscriptionManager.deleteSubscription(subscription);
|
||||
return o;
|
||||
};
|
||||
}
|
||||
|
@ -258,7 +258,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
|||
"Updating Subscription for " + info.getUrl(),
|
||||
R.string.subscription_update_failed);
|
||||
|
||||
disposables.add(subscriptionService.updateChannelInfo(info)
|
||||
disposables.add(subscriptionManager.updateChannelInfo(info)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(onComplete, onError));
|
||||
|
@ -288,7 +288,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
|||
private Consumer<List<SubscriptionEntity>> getSubscribeUpdateMonitor(final ChannelInfo info) {
|
||||
return (List<SubscriptionEntity> subscriptionEntities) -> {
|
||||
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 (subscriptionEntities.isEmpty()) {
|
||||
|
@ -300,7 +300,7 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
|||
info.getAvatarUrl(),
|
||||
info.getDescription(),
|
||||
info.getSubscriberCount());
|
||||
subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, mapOnSubscribe(channel));
|
||||
subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, mapOnSubscribe(channel, info));
|
||||
} else {
|
||||
if (DEBUG) Log.d(TAG, "Found subscription to this channel!");
|
||||
final SubscriptionEntity subscription = subscriptionEntities.get(0);
|
||||
|
@ -440,16 +440,12 @@ public class ChannelFragment extends BaseListInfoFragment<ChannelInfo> {
|
|||
protected boolean onError(Throwable exception) {
|
||||
if (super.onError(exception)) return true;
|
||||
|
||||
if (exception instanceof ContentNotAvailableException) {
|
||||
showError(getString(R.string.content_not_available), false);
|
||||
} else {
|
||||
int errorId = exception instanceof ExtractionException ? R.string.parsing_error : R.string.general_error;
|
||||
onUnrecoverableError(exception,
|
||||
UserAction.REQUESTED_CHANNEL,
|
||||
NewPipe.getNameOfService(serviceId),
|
||||
url,
|
||||
errorId);
|
||||
}
|
||||
int errorId = exception instanceof ExtractionException
|
||||
? R.string.parsing_error : R.string.general_error;
|
||||
|
||||
onUnrecoverableError(exception, UserAction.REQUESTED_CHANNEL,
|
||||
NewPipe.getNameOfService(serviceId), url, errorId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
@ -122,7 +122,7 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
|||
this.useGridVariant = useGridVariant;
|
||||
}
|
||||
|
||||
public void addInfoItemList(@Nullable final List<InfoItem> data) {
|
||||
public void addInfoItemList(@Nullable final List<? extends InfoItem> data) {
|
||||
if (data == null) {
|
||||
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) {
|
||||
if (data == null) {
|
||||
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) {
|
||||
long streamId;
|
||||
if (item instanceof StreamStatisticsEntry) {
|
||||
streamId = ((StreamStatisticsEntry) item).streamId;
|
||||
streamId = ((StreamStatisticsEntry) item).getStreamId();
|
||||
} else if (item instanceof PlaylistStreamEntity) {
|
||||
streamId = ((PlaylistStreamEntity) item).getStreamUid();
|
||||
} else if (item instanceof PlaylistStreamEntry) {
|
||||
streamId = ((PlaylistStreamEntry) item).streamId;
|
||||
streamId = ((PlaylistStreamEntry) item).getStreamId();
|
||||
} else {
|
||||
result.add(null);
|
||||
continue;
|
||||
|
|
|
@ -76,11 +76,11 @@ public class StatisticsPlaylistFragment
|
|||
switch (sortMode) {
|
||||
case LAST_PLAYED:
|
||||
Collections.sort(results, (left, right) ->
|
||||
right.latestAccessDate.compareTo(left.latestAccessDate));
|
||||
right.getLatestAccessDate().compareTo(left.getLatestAccessDate()));
|
||||
return results;
|
||||
case MOST_PLAYED:
|
||||
Collections.sort(results, (left, right) ->
|
||||
Long.compare(right.watchCount, left.watchCount));
|
||||
Long.compare(right.getWatchCount(), left.getWatchCount()));
|
||||
return results;
|
||||
default: return null;
|
||||
}
|
||||
|
@ -153,9 +153,9 @@ public class StatisticsPlaylistFragment
|
|||
if (selectedItem instanceof StreamStatisticsEntry) {
|
||||
final StreamStatisticsEntry item = (StreamStatisticsEntry) selectedItem;
|
||||
NavigationHelper.openVideoDetailFragment(getFM(),
|
||||
item.serviceId,
|
||||
item.url,
|
||||
item.title);
|
||||
item.getStreamEntity().getServiceId(),
|
||||
item.getStreamEntity().getUrl(),
|
||||
item.getStreamEntity().getTitle());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -402,7 +402,7 @@ public class StatisticsPlaylistFragment
|
|||
.get(index);
|
||||
if(infoItem instanceof StreamStatisticsEntry) {
|
||||
final StreamStatisticsEntry entry = (StreamStatisticsEntry) infoItem;
|
||||
final Disposable onDelete = recordManager.deleteStreamHistory(entry.streamId)
|
||||
final Disposable onDelete = recordManager.deleteStreamHistory(entry.getStreamId())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
howManyDeleted -> {
|
||||
|
|
|
@ -52,12 +52,12 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
|
|||
if (!(localItem instanceof PlaylistStreamEntry)) return;
|
||||
final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem;
|
||||
|
||||
itemVideoTitleView.setText(item.title);
|
||||
itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.uploader,
|
||||
NewPipe.getNameOfService(item.serviceId)));
|
||||
itemVideoTitleView.setText(item.getStreamEntity().getTitle());
|
||||
itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.getStreamEntity().getUploader(),
|
||||
NewPipe.getNameOfService(item.getStreamEntity().getServiceId())));
|
||||
|
||||
if (item.duration > 0) {
|
||||
itemDurationView.setText(Localization.getDurationString(item.duration));
|
||||
if (item.getStreamEntity().getDuration() > 0) {
|
||||
itemDurationView.setText(Localization.getDurationString(item.getStreamEntity().getDuration()));
|
||||
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
|
||||
R.color.duration_background_color));
|
||||
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);
|
||||
if (state != null) {
|
||||
itemProgressView.setVisibility(View.VISIBLE);
|
||||
itemProgressView.setMax((int) item.duration);
|
||||
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
|
||||
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
|
||||
} else {
|
||||
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
|
||||
itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView,
|
||||
itemBuilder.displayImage(item.getStreamEntity().getThumbnailUrl(), itemThumbnailView,
|
||||
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS);
|
||||
|
||||
itemView.setOnClickListener(view -> {
|
||||
|
@ -102,8 +102,8 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
|
|||
final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem;
|
||||
|
||||
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{ add(localItem); }}).blockingGet().get(0);
|
||||
if (state != null && item.duration > 0) {
|
||||
itemProgressView.setMax((int) item.duration);
|
||||
if (state != null && item.getStreamEntity().getDuration() > 0) {
|
||||
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
|
||||
if (itemProgressView.getVisibility() == View.VISIBLE) {
|
||||
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
|
||||
} else {
|
||||
|
|
|
@ -71,9 +71,9 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
|
|||
private String getStreamInfoDetailLine(final StreamStatisticsEntry entry,
|
||||
final DateFormat dateFormat) {
|
||||
final String watchCount = Localization.shortViewCount(itemBuilder.getContext(),
|
||||
entry.watchCount);
|
||||
final String uploadDate = dateFormat.format(entry.latestAccessDate);
|
||||
final String serviceName = NewPipe.getNameOfService(entry.serviceId);
|
||||
entry.getWatchCount());
|
||||
final String uploadDate = dateFormat.format(entry.getLatestAccessDate());
|
||||
final String serviceName = NewPipe.getNameOfService(entry.getStreamEntity().getServiceId());
|
||||
return Localization.concatenateStrings(watchCount, uploadDate, serviceName);
|
||||
}
|
||||
|
||||
|
@ -82,11 +82,11 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
|
|||
if (!(localItem instanceof StreamStatisticsEntry)) return;
|
||||
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
|
||||
|
||||
itemVideoTitleView.setText(item.title);
|
||||
itemUploaderView.setText(item.uploader);
|
||||
itemVideoTitleView.setText(item.getStreamEntity().getTitle());
|
||||
itemUploaderView.setText(item.getStreamEntity().getUploader());
|
||||
|
||||
if (item.duration > 0) {
|
||||
itemDurationView.setText(Localization.getDurationString(item.duration));
|
||||
if (item.getStreamEntity().getDuration() > 0) {
|
||||
itemDurationView.setText(Localization.getDurationString(item.getStreamEntity().getDuration()));
|
||||
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
|
||||
R.color.duration_background_color));
|
||||
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);
|
||||
if (state != null) {
|
||||
itemProgressView.setVisibility(View.VISIBLE);
|
||||
itemProgressView.setMax((int) item.duration);
|
||||
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
|
||||
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
|
||||
} else {
|
||||
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
|
||||
itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView,
|
||||
itemBuilder.displayImage(item.getStreamEntity().getThumbnailUrl(), itemThumbnailView,
|
||||
ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS);
|
||||
|
||||
itemView.setOnClickListener(view -> {
|
||||
|
@ -133,8 +133,8 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
|
|||
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
|
||||
|
||||
StreamStateEntity state = historyRecordManager.loadLocalStreamStateBatch(new ArrayList<LocalItem>() {{ add(localItem); }}).blockingGet().get(0);
|
||||
if (state != null && item.duration > 0) {
|
||||
itemProgressView.setMax((int) item.duration);
|
||||
if (state != null && item.getStreamEntity().getDuration() > 0) {
|
||||
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
|
||||
if (itemProgressView.getVisibility() == View.VISIBLE) {
|
||||
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()));
|
||||
} else {
|
||||
|
|
|
@ -168,7 +168,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||
if (selectedItem instanceof PlaylistStreamEntry) {
|
||||
final PlaylistStreamEntry item = (PlaylistStreamEntry) selectedItem;
|
||||
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;
|
||||
|
||||
if (!itemListAdapter.getItemsList().isEmpty()) {
|
||||
newThumbnailUrl = ((PlaylistStreamEntry) itemListAdapter.getItemsList().get(0)).thumbnailUrl;
|
||||
newThumbnailUrl = ((PlaylistStreamEntry) itemListAdapter.getItemsList().get(0)).getStreamEntity().getThumbnailUrl();
|
||||
} else {
|
||||
newThumbnailUrl = "drawable://" + R.drawable.dummy_thumbnail_playlist;
|
||||
}
|
||||
|
@ -434,7 +434,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||
if (itemListAdapter == null) return;
|
||||
|
||||
itemListAdapter.removeItem(item);
|
||||
if (playlistManager.getPlaylistThumbnail(playlistId).equals(item.thumbnailUrl))
|
||||
if (playlistManager.getPlaylistThumbnail(playlistId).equals(item.getStreamEntity().getThumbnailUrl()))
|
||||
updateThumbnailUrl();
|
||||
|
||||
setVideoCount(itemListAdapter.getItemsList().size());
|
||||
|
@ -472,7 +472,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||
List<Long> streamIds = new ArrayList<>(items.size());
|
||||
for (final LocalItem item : items) {
|
||||
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(
|
||||
(fragment, infoItemDuplicate) -> NavigationHelper.playOnBackgroundPlayer(context, getPlayQueueStartingAt(item), true));
|
||||
StreamDialogEntry.set_as_playlist_thumbnail.setCustomAction(
|
||||
(fragment, infoItemDuplicate) -> changeThumbnailUrl(item.thumbnailUrl));
|
||||
(fragment, infoItemDuplicate) -> changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl()));
|
||||
StreamDialogEntry.delete.setCustomAction(
|
||||
(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.schabi.newpipe.R;
|
||||
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.UserAction;
|
||||
import org.schabi.newpipe.local.subscription.ImportExportEventListener;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionService;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
|
@ -57,7 +56,7 @@ public abstract class BaseImportExportService extends Service {
|
|||
protected NotificationManagerCompat notificationManager;
|
||||
protected NotificationCompat.Builder notificationBuilder;
|
||||
|
||||
protected SubscriptionService subscriptionService;
|
||||
protected SubscriptionManager subscriptionManager;
|
||||
protected final CompositeDisposable disposables = new CompositeDisposable();
|
||||
protected final PublishProcessor<String> notificationUpdater = PublishProcessor.create();
|
||||
|
||||
|
@ -70,7 +69,7 @@ public abstract class BaseImportExportService extends Service {
|
|||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
subscriptionService = SubscriptionService.getInstance(this);
|
||||
subscriptionManager = new SubscriptionManager(this);
|
||||
setupNotification();
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
package org.schabi.newpipe.local.subscription;
|
||||
package org.schabi.newpipe.local.subscription.services;
|
||||
|
||||
public interface ImportExportEventListener {
|
||||
/**
|
|
@ -17,7 +17,7 @@
|
|||
* 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;
|
||||
|
|
@ -29,7 +29,6 @@ import org.reactivestreams.Subscription;
|
|||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||
import org.schabi.newpipe.local.subscription.ImportExportJsonHelper;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
|
@ -96,7 +95,7 @@ public class SubscriptionsExportService extends BaseImportExportService {
|
|||
private void startExport() {
|
||||
showToast(R.string.export_ongoing);
|
||||
|
||||
subscriptionService.subscriptionTable()
|
||||
subscriptionManager.subscriptionTable()
|
||||
.getAll()
|
||||
.take(1)
|
||||
.map(subscriptionEntities -> {
|
||||
|
|
|
@ -33,7 +33,6 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
|||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
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.ExtractorHelper;
|
||||
|
||||
|
@ -180,6 +179,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
|
|||
|
||||
.observeOn(Schedulers.io())
|
||||
.doOnNext(getNotificationsConsumer())
|
||||
|
||||
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
||||
.map(upsertBatch())
|
||||
|
||||
|
@ -204,6 +204,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
|
|||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
Log.e(TAG, "Got an error!", error);
|
||||
handleError(error);
|
||||
}
|
||||
|
||||
|
@ -242,7 +243,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
|
|||
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.os.Build;
|
||||
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.util.Log;
|
||||
import android.view.View;
|
||||
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.Player;
|
||||
import com.google.android.exoplayer2.source.MediaSource;
|
||||
|
@ -389,7 +389,6 @@ public final class BackgroundPlayer extends Service {
|
|||
@Override
|
||||
public void onPrepared(boolean playWhenReady) {
|
||||
super.onPrepared(playWhenReady);
|
||||
simpleExoPlayer.setVolume(1f);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -398,6 +397,12 @@ public final class BackgroundPlayer extends Service {
|
|||
updatePlayback();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMuteUnmuteButtonClicked() {
|
||||
super.onMuteUnmuteButtonClicked();
|
||||
updatePlayback();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUpdateProgress(int currentProgress, int duration, int bufferPercent) {
|
||||
updateProgress(currentProgress, duration, bufferPercent);
|
||||
|
|
|
@ -153,6 +153,8 @@ public abstract class BasePlayer implements
|
|||
public static final String START_PAUSED = "start_paused";
|
||||
@NonNull
|
||||
public static final String SELECT_ON_APPEND = "select_on_append";
|
||||
@NonNull
|
||||
public static final String IS_MUTED = "is_muted";
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Playback
|
||||
|
@ -275,6 +277,7 @@ public abstract class BasePlayer implements
|
|||
final float playbackPitch = intent.getFloatExtra(PLAYBACK_PITCH, getPlaybackPitch());
|
||||
final boolean playbackSkipSilence = intent.getBooleanExtra(PLAYBACK_SKIP_SILENCE,
|
||||
getPlaybackSkipSilence());
|
||||
final boolean isMuted = intent.getBooleanExtra(IS_MUTED, simpleExoPlayer == null ? false : isMuted());
|
||||
|
||||
// seek to timestamp if stream is already playing
|
||||
if (simpleExoPlayer != null
|
||||
|
@ -293,7 +296,7 @@ public abstract class BasePlayer implements
|
|||
stateLoader = recordManager.loadStreamState(item)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doFinally(() -> initPlayback(queue, repeatMode, playbackSpeed, playbackPitch, playbackSkipSilence,
|
||||
/*playOnInit=*/true))
|
||||
/*playOnInit=*/true, isMuted))
|
||||
.subscribe(
|
||||
state -> queue.setRecovery(queue.getIndex(), state.getProgressTime()),
|
||||
error -> {
|
||||
|
@ -306,7 +309,7 @@ public abstract class BasePlayer implements
|
|||
}
|
||||
// Good to go...
|
||||
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,
|
||||
|
@ -314,7 +317,8 @@ public abstract class BasePlayer implements
|
|||
final float playbackSpeed,
|
||||
final float playbackPitch,
|
||||
final boolean playbackSkipSilence,
|
||||
final boolean playOnReady) {
|
||||
final boolean playOnReady,
|
||||
final boolean isMuted) {
|
||||
destroyPlayer();
|
||||
initPlayer(playOnReady);
|
||||
setRepeatMode(repeatMode);
|
||||
|
@ -327,6 +331,8 @@ public abstract class BasePlayer implements
|
|||
|
||||
if (playQueueAdapter != null) playQueueAdapter.dispose();
|
||||
playQueueAdapter = new PlayQueueAdapter(context, playQueue);
|
||||
|
||||
simpleExoPlayer.setVolume(isMuted ? 0 : 1);
|
||||
}
|
||||
|
||||
public void destroyPlayer() {
|
||||
|
@ -532,6 +538,18 @@ public abstract class BasePlayer implements
|
|||
if (simpleExoPlayer == null) return;
|
||||
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
|
||||
|
|
|
@ -34,14 +34,17 @@ import android.os.Bundle;
|
|||
import android.os.Handler;
|
||||
import android.preference.PreferenceManager;
|
||||
import android.provider.Settings;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
|
||||
import android.util.DisplayMetrics;
|
||||
import android.util.Log;
|
||||
import android.util.TypedValue;
|
||||
|
@ -116,7 +119,8 @@ public final class MainVideoPlayer extends AppCompatActivity
|
|||
|
||||
private SharedPreferences defaultPreferences;
|
||||
|
||||
@Nullable private PlayerState playerState;
|
||||
@Nullable
|
||||
private PlayerState playerState;
|
||||
private boolean isInMultiWindow;
|
||||
private boolean isBackPressed;
|
||||
|
||||
|
@ -130,11 +134,13 @@ public final class MainVideoPlayer extends AppCompatActivity
|
|||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
assureCorrectAppLanguage(this);
|
||||
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);
|
||||
ThemeHelper.setTheme(this);
|
||||
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);
|
||||
|
||||
WindowManager.LayoutParams lp = getWindow().getAttributes();
|
||||
|
@ -220,7 +226,7 @@ public final class MainVideoPlayer extends AppCompatActivity
|
|||
playerImpl.setPlaybackQuality(playerState.getPlaybackQuality());
|
||||
playerImpl.initPlayback(playerState.getPlayQueue(), playerState.getRepeatMode(),
|
||||
playerState.getPlaybackSpeed(), playerState.getPlaybackPitch(),
|
||||
playerState.isPlaybackSkipSilence(), playerState.wasPlaying());
|
||||
playerState.isPlaybackSkipSilence(), playerState.wasPlaying(), playerImpl.isMuted());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -396,6 +402,12 @@ public final class MainVideoPlayer extends AppCompatActivity
|
|||
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() {
|
||||
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 switchPopupButton;
|
||||
private ImageButton switchBackgroundButton;
|
||||
private ImageButton muteButton;
|
||||
|
||||
private RelativeLayout windowRootLayout;
|
||||
private View secondaryControls;
|
||||
|
@ -484,6 +497,7 @@ public final class MainVideoPlayer extends AppCompatActivity
|
|||
this.shareButton = rootView.findViewById(R.id.share);
|
||||
this.toggleOrientationButton = rootView.findViewById(R.id.toggleOrientation);
|
||||
this.switchBackgroundButton = rootView.findViewById(R.id.switchBackground);
|
||||
this.muteButton = rootView.findViewById(R.id.switchMute);
|
||||
this.switchPopupButton = rootView.findViewById(R.id.switchPopup);
|
||||
|
||||
this.queueLayout = findViewById(R.id.playQueuePanel);
|
||||
|
@ -535,6 +549,7 @@ public final class MainVideoPlayer extends AppCompatActivity
|
|||
shareButton.setOnClickListener(this);
|
||||
toggleOrientationButton.setOnClickListener(this);
|
||||
switchBackgroundButton.setOnClickListener(this);
|
||||
muteButton.setOnClickListener(this);
|
||||
switchPopupButton.setOnClickListener(this);
|
||||
|
||||
getRootView().addOnLayoutChangeListener((view, l, t, r, b, ol, ot, or, ob) -> {
|
||||
|
@ -653,7 +668,8 @@ public final class MainVideoPlayer extends AppCompatActivity
|
|||
this.getPlaybackSkipSilence(),
|
||||
this.getPlaybackQuality(),
|
||||
false,
|
||||
!isPlaying()
|
||||
!isPlaying(),
|
||||
isMuted()
|
||||
);
|
||||
context.startService(intent);
|
||||
|
||||
|
@ -677,7 +693,8 @@ public final class MainVideoPlayer extends AppCompatActivity
|
|||
this.getPlaybackSkipSilence(),
|
||||
this.getPlaybackQuality(),
|
||||
false,
|
||||
!isPlaying()
|
||||
!isPlaying(),
|
||||
isMuted()
|
||||
);
|
||||
context.startService(intent);
|
||||
|
||||
|
@ -686,6 +703,12 @@ public final class MainVideoPlayer extends AppCompatActivity
|
|||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMuteUnmuteButtonClicked() {
|
||||
super.onMuteUnmuteButtonClicked();
|
||||
setMuteButton(muteButton, playerImpl.isMuted());
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
|
@ -723,6 +746,9 @@ public final class MainVideoPlayer extends AppCompatActivity
|
|||
} else if (v.getId() == switchBackgroundButton.getId()) {
|
||||
onPlayBackgroundButtonClicked();
|
||||
|
||||
} else if (v.getId() == muteButton.getId()) {
|
||||
onMuteUnmuteButtonClicked();
|
||||
|
||||
} else if (v.getId() == closeButton.getId()) {
|
||||
onPlaybackShutdown();
|
||||
return;
|
||||
|
@ -770,6 +796,7 @@ public final class MainVideoPlayer extends AppCompatActivity
|
|||
animateView(secondaryControls, SLIDE_AND_ALPHA, !isMoreControlsVisible,
|
||||
DEFAULT_CONTROLS_DURATION);
|
||||
showControls(DEFAULT_CONTROLS_DURATION);
|
||||
setMuteButton(muteButton, playerImpl.isMuted());
|
||||
}
|
||||
|
||||
private void onShareClicked() {
|
||||
|
@ -1074,6 +1101,10 @@ public final class MainVideoPlayer extends AppCompatActivity
|
|||
return repeatButton;
|
||||
}
|
||||
|
||||
public ImageButton getMuteButton() {
|
||||
return muteButton;
|
||||
}
|
||||
|
||||
public ImageButton getPlayPauseButton() {
|
||||
return playPauseButton;
|
||||
}
|
||||
|
@ -1088,7 +1119,8 @@ public final class MainVideoPlayer extends AppCompatActivity
|
|||
|
||||
@Override
|
||||
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) {
|
||||
playerImpl.onFastForward();
|
||||
|
@ -1184,7 +1216,8 @@ public final class MainVideoPlayer extends AppCompatActivity
|
|||
layoutParams.screenBrightness = currentProgressPercent;
|
||||
getWindow().setAttributes(layoutParams);
|
||||
|
||||
if (DEBUG) Log.d(TAG, "onScroll().brightnessControl, currentBrightness = " + currentProgressPercent);
|
||||
if (DEBUG)
|
||||
Log.d(TAG, "onScroll().brightnessControl, currentBrightness = " + currentProgressPercent);
|
||||
|
||||
final int resId =
|
||||
currentProgressPercent < 0.25 ? R.drawable.ic_brightness_low_white_72dp
|
||||
|
@ -1223,7 +1256,8 @@ public final class MainVideoPlayer extends AppCompatActivity
|
|||
@Override
|
||||
public boolean onTouch(View v, MotionEvent event) {
|
||||
//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);
|
||||
if (event.getAction() == MotionEvent.ACTION_UP && isMoving) {
|
||||
isMoving = false;
|
||||
|
|
|
@ -571,7 +571,8 @@ public final class PopupVideoPlayer extends Service {
|
|||
this.getPlaybackSkipSilence(),
|
||||
this.getPlaybackQuality(),
|
||||
false,
|
||||
!isPlaying()
|
||||
!isPlaying(),
|
||||
isMuted()
|
||||
);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
context.startActivity(intent);
|
||||
|
@ -607,6 +608,12 @@ public final class PopupVideoPlayer extends Service {
|
|||
updatePlayback();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMuteUnmuteButtonClicked() {
|
||||
super.onMuteUnmuteButtonClicked();
|
||||
updatePlayback();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUpdateProgress(int currentProgress, int duration, int bufferPercent) {
|
||||
updateProgress(currentProgress, duration, bufferPercent);
|
||||
|
|
|
@ -3,14 +3,17 @@ package org.schabi.newpipe.player;
|
|||
import android.content.ComponentName;
|
||||
import android.content.Intent;
|
||||
import android.content.ServiceConnection;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.provider.Settings;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
|
||||
import android.util.Log;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
|
@ -92,6 +95,8 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
|
|||
private TextView playbackSpeedButton;
|
||||
private TextView playbackPitchButton;
|
||||
|
||||
private Menu menu;
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Abstracts
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
@ -145,8 +150,10 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
|
|||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
this.menu = menu;
|
||||
getMenuInflater().inflate(R.menu.menu_play_queue, menu);
|
||||
getMenuInflater().inflate(getPlayerOptionMenuResource(), menu);
|
||||
onMaybeMuteChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -162,6 +169,9 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
|
|||
case R.id.action_append_playlist:
|
||||
appendAllToPlaylist();
|
||||
return true;
|
||||
case R.id.action_mute:
|
||||
player.onMuteUnmuteButtonClicked();
|
||||
return true;
|
||||
case R.id.action_system_audio:
|
||||
startActivity(new Intent(Settings.ACTION_SOUND_SETTINGS));
|
||||
return true;
|
||||
|
@ -194,7 +204,8 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
|
|||
this.player.getPlaybackSkipSilence(),
|
||||
null,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
this.player.isMuted()
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.putExtra(BasePlayer.START_PAUSED, !this.player.isPlaying());
|
||||
}
|
||||
|
@ -554,6 +565,7 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
|
|||
onPlayModeChanged(repeatMode, shuffled);
|
||||
onPlaybackParameterChanged(parameters);
|
||||
onMaybePlaybackAdapterChanged();
|
||||
onMaybeMuteChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -676,4 +688,23 @@ public abstract class ServicePlayerActivity extends AppCompatActivity
|
|||
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_KIOSK("requested kiosk"),
|
||||
REQUESTED_COMMENTS("requested comments"),
|
||||
REQUESTED_FEED("requested feed"),
|
||||
DELETE_FROM_HISTORY("delete from history"),
|
||||
PLAY_STREAM("Play stream"),
|
||||
DOWNLOAD_POSTPROCESSING("download post-processing"),
|
||||
|
|
|
@ -17,6 +17,7 @@ import androidx.preference.Preference;
|
|||
import com.nononsenseapps.filepicker.Utils;
|
||||
import com.nostra13.universalimageloader.core.ImageLoader;
|
||||
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.localization.ContentCountry;
|
||||
|
@ -168,6 +169,9 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
|
|||
|
||||
private void exportDatabase(String path) {
|
||||
try {
|
||||
//checkpoint before export
|
||||
NewPipeDatabase.checkpoint();
|
||||
|
||||
ZipOutputStream outZip = new ZipOutputStream(
|
||||
new BufferedOutputStream(
|
||||
new FileOutputStream(path)));
|
||||
|
|
|
@ -23,7 +23,7 @@ package org.schabi.newpipe.settings;
|
|||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Environment;
|
||||
import android.preference.PreferenceManager;
|
||||
import androidx.preference.PreferenceManager;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
|
|
|
@ -18,9 +18,9 @@ import com.nostra13.universalimageloader.core.ImageLoader;
|
|||
|
||||
import org.schabi.newpipe.R;
|
||||
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.UserAction;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Vector;
|
||||
|
@ -99,8 +99,8 @@ public class SelectChannelFragment extends DialogFragment {
|
|||
emptyView.setVisibility(View.GONE);
|
||||
|
||||
|
||||
SubscriptionService subscriptionService = SubscriptionService.getInstance(getContext());
|
||||
subscriptionService.getSubscription().toObservable()
|
||||
SubscriptionManager subscriptionManager = new SubscriptionManager(getContext());
|
||||
subscriptionManager.subscriptions().toObservable()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(getSubscriptionObserver());
|
||||
|
|
|
@ -6,11 +6,15 @@ import android.os.Build;
|
|||
import android.os.Bundle;
|
||||
import android.provider.Settings;
|
||||
|
||||
import android.text.format.DateUtils;
|
||||
import android.widget.Toast;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.preference.ListPreference;
|
||||
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.util.PermissionHelper;
|
||||
|
||||
|
@ -22,23 +26,7 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment {
|
|||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
//initializing R.array.seek_duration_description to display the translation of seconds
|
||||
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);
|
||||
updateSeekOptions();
|
||||
|
||||
listener = (sharedPreferences, s) -> {
|
||||
|
||||
|
@ -58,10 +46,59 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment {
|
|||
.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
|
||||
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
|
||||
public String getTabName(Context context) {
|
||||
return context.getString(R.string.fragment_whats_new);
|
||||
return context.getString(R.string.fragment_feed_title);
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
|
|
|
@ -37,6 +37,7 @@ public class WebMReader {
|
|||
private final static int ID_DefaultDuration = 0x3E383;
|
||||
private final static int ID_FlagLacing = 0x1C;
|
||||
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_Timecode = 0x67;
|
||||
|
@ -332,6 +333,10 @@ public class WebMReader {
|
|||
break;
|
||||
case ID_CodecDelay:
|
||||
entry.codecDelay = readNumber(elem);
|
||||
break;
|
||||
case ID_SeekPreRoll:
|
||||
entry.seekPreRoll = readNumber(elem);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
@ -414,8 +419,9 @@ public class WebMReader {
|
|||
public byte[] codecPrivate;
|
||||
public byte[] bMetadata;
|
||||
public TrackKind kind;
|
||||
public long defaultDuration;
|
||||
public long codecDelay;
|
||||
public long defaultDuration = -1;
|
||||
public long codecDelay = -1;
|
||||
public long seekPreRoll = -1;
|
||||
}
|
||||
|
||||
public class Segment {
|
||||
|
|
|
@ -23,7 +23,10 @@ public class WebMWriter implements Closeable {
|
|||
private final static int BUFFER_SIZE = 8 * 1024;
|
||||
private final static int DEFAULT_TIMECODE_SCALE = 1000000;
|
||||
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 SharpStream[] sourceTracks;
|
||||
|
@ -38,15 +41,18 @@ public class WebMWriter implements Closeable {
|
|||
private Segment[] readersSegment;
|
||||
private Cluster[] readersCluster;
|
||||
|
||||
private int[] predefinedDurations;
|
||||
private ArrayList<ClusterInfo> clustersOffsetsSizes;
|
||||
|
||||
private byte[] outBuffer;
|
||||
private ByteBuffer outByteBuffer;
|
||||
|
||||
public WebMWriter(SharpStream... source) {
|
||||
sourceTracks = source;
|
||||
readers = new WebMReader[sourceTracks.length];
|
||||
infoTracks = new WebMTrack[sourceTracks.length];
|
||||
outBuffer = new byte[BUFFER_SIZE];
|
||||
outByteBuffer = ByteBuffer.wrap(outBuffer);
|
||||
clustersOffsetsSizes = new ArrayList<>(256);
|
||||
}
|
||||
|
||||
public WebMTrack[] getTracksFromSource(int sourceIndex) throws IllegalStateException {
|
||||
|
@ -83,11 +89,9 @@ public class WebMWriter implements Closeable {
|
|||
try {
|
||||
readersSegment = new Segment[readers.length];
|
||||
readersCluster = new Cluster[readers.length];
|
||||
predefinedDurations = new int[readers.length];
|
||||
|
||||
for (int i = 0; i < readers.length; i++) {
|
||||
infoTracks[i] = readers[i].selectTrack(trackIndex[i]);
|
||||
predefinedDurations[i] = -1;
|
||||
readersSegment[i] = readers[i].getNextSegment();
|
||||
}
|
||||
} finally {
|
||||
|
@ -118,6 +122,8 @@ public class WebMWriter implements Closeable {
|
|||
readersSegment = null;
|
||||
readersCluster = null;
|
||||
outBuffer = null;
|
||||
outByteBuffer = null;
|
||||
clustersOffsetsSizes = null;
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
long baseSegmentOffset = written + listBuffer.get(0).length;
|
||||
long segmentOffset = written + listBuffer.get(0).length;
|
||||
|
||||
/* seek head */
|
||||
listBuffer.add(new byte[]{
|
||||
|
@ -177,20 +183,22 @@ public class WebMWriter implements Closeable {
|
|||
/* tracks */
|
||||
listBuffer.addAll(makeTracks());
|
||||
|
||||
for (byte[] buff : listBuffer) {
|
||||
dump(buff, out);
|
||||
}
|
||||
dump(listBuffer, out);
|
||||
|
||||
// reserve space for Cues element, but is a waste of space (actually is 64 KiB)
|
||||
// TODO: better Cue maker
|
||||
long cueReservedOffset = written;
|
||||
dump(new byte[]{(byte) 0xec, 0x20, (byte) 0xff, (byte) 0xfb}, out);
|
||||
int reserved = (1024 * 63) - 4;
|
||||
while (reserved > 0) {
|
||||
int write = Math.min(reserved, outBuffer.length);
|
||||
out.write(outBuffer, 0, write);
|
||||
reserved -= write;
|
||||
written += write;
|
||||
// reserve space for Cues element
|
||||
long cueOffset = written;
|
||||
make_EBML_void(out, CUE_RESERVE_SIZE, true);
|
||||
|
||||
int[] defaultSampleDuration = new int[infoTracks.length];
|
||||
long[] duration = new long[infoTracks.length];
|
||||
|
||||
for (int i = 0; i < infoTracks.length; i++) {
|
||||
if (infoTracks[i].defaultDuration < 0) {
|
||||
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
|
||||
|
@ -198,16 +206,8 @@ public class WebMWriter implements Closeable {
|
|||
long nextCueTime = infoTracks[cuesForTrackId].trackType == 1 ? -1 : 0;
|
||||
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;
|
||||
long currentClusterOffset = makeCluster(out, bTimecode, 0, clusterOffsets, clusterSizes);
|
||||
long currentClusterOffset = makeCluster(out, 0, 0, true);
|
||||
|
||||
long baseTimecode = 0;
|
||||
long limitTimecode = -1;
|
||||
|
@ -239,8 +239,7 @@ public class WebMWriter implements Closeable {
|
|||
newClusterByTrackId = -1;
|
||||
baseTimecode = bloq.absoluteTimecode;
|
||||
limitTimecode = baseTimecode + INTERV;
|
||||
bTimecode = makeTimecode(baseTimecode);
|
||||
currentClusterOffset = makeCluster(out, bTimecode, currentClusterOffset, clusterOffsets, clusterSizes);
|
||||
currentClusterOffset = makeCluster(out, baseTimecode, currentClusterOffset, true);
|
||||
}
|
||||
|
||||
if (cuesForTrackId == i) {
|
||||
|
@ -248,19 +247,18 @@ public class WebMWriter implements Closeable {
|
|||
if (nextCueTime > -1) {
|
||||
nextCueTime += DEFAULT_CUES_EACH_MS;
|
||||
}
|
||||
keyFrames.add(
|
||||
new KeyFrame(baseSegmentOffset, currentClusterOffset - 8, written, bTimecode.length, bloq.absoluteTimecode)
|
||||
);
|
||||
keyFrames.add(new KeyFrame(segmentOffset, currentClusterOffset, written, bloq.absoluteTimecode));
|
||||
}
|
||||
}
|
||||
|
||||
writeBlock(out, bloq, baseTimecode);
|
||||
blockWritten++;
|
||||
|
||||
if (bloq.absoluteTimecode > duration) {
|
||||
duration = bloq.absoluteTimecode;
|
||||
durationFromTrackId = bloq.trackNumber;
|
||||
if (defaultSampleDuration[i] < 0 && duration[i] >= 0) {
|
||||
// if the sample duration in unknown, calculate using current_duration - previous_duration
|
||||
defaultSampleDuration[i] = (int) (bloq.absoluteTimecode - duration[i]);
|
||||
}
|
||||
duration[i] = bloq.absoluteTimecode;
|
||||
|
||||
if (limitTimecode < 0) {
|
||||
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;
|
||||
|
||||
/* ---- final step write offsets and sizes ---- */
|
||||
/* Segment size */
|
||||
seekTo(out, offsetSegmentSizeSet);
|
||||
writeLong(out, segmentSize);
|
||||
outByteBuffer.putLong(0, segmentSize);
|
||||
out.write(outBuffer, 1, DataReader.LONG_SIZE - 1);
|
||||
|
||||
if (predefinedDurations[durationFromTrackId] > -1) {
|
||||
duration += predefinedDurations[durationFromTrackId];// this value is full-filled in makeTrackEntry() method
|
||||
/* Segment duration */
|
||||
long longestDuration = 0;
|
||||
for (int i = 0; i < duration.length; i++) {
|
||||
if (defaultSampleDuration[i] > 0) {
|
||||
duration[i] += defaultSampleDuration[i];
|
||||
}
|
||||
if (duration[i] > longestDuration) {
|
||||
longestDuration = duration[i];
|
||||
}
|
||||
}
|
||||
seekTo(out, offsetInfoDurationSet);
|
||||
writeFloat(out, duration);
|
||||
outByteBuffer.putFloat(0, longestDuration);
|
||||
dump(outBuffer, DataReader.FLOAT_SIZE, out);
|
||||
|
||||
firstClusterOffset -= baseSegmentOffset;
|
||||
seekTo(out, offsetClusterSet);
|
||||
writeInt(out, firstClusterOffset);
|
||||
/* first Cluster offset */
|
||||
firstClusterOffset -= segmentOffset;
|
||||
writeInt(out, offsetClusterSet, firstClusterOffset);
|
||||
|
||||
seekTo(out, cueReservedOffset);
|
||||
seekTo(out, cueOffset);
|
||||
|
||||
/* Cue */
|
||||
dump(new byte[]{0x1c, 0x53, (byte) 0xbb, 0x6b, 0x20, 0x00, 0x00}, out);
|
||||
short cueSize = 0;
|
||||
dump(new byte[]{0x1c, 0x53, (byte) 0xbb, 0x6b, 0x20, 0x00, 0x00}, out);// header size is 7
|
||||
|
||||
for (KeyFrame keyFrame : keyFrames) {
|
||||
for (byte[] buffer : makeCuePoint(cuesForTrackId, keyFrame)) {
|
||||
dump(buffer, out);
|
||||
if (written >= (cueReservedOffset + 65535 - 16)) {
|
||||
throw new IOException("Too many Cues");
|
||||
int size = makeCuePoint(cuesForTrackId, keyFrame, outBuffer);
|
||||
|
||||
if ((cueSize + size + 7 + MINIMUM_EBML_VOID_SIZE) > CUE_RESERVE_SIZE) {
|
||||
break;// no space left
|
||||
}
|
||||
|
||||
cueSize += size;
|
||||
dump(outBuffer, size, out);
|
||||
}
|
||||
}
|
||||
short cueSize = (short) (written - cueReservedOffset - 7);
|
||||
|
||||
/* EBML Void */
|
||||
ByteBuffer voidBuffer = ByteBuffer.allocate(4);
|
||||
voidBuffer.putShort((short) 0xec20);
|
||||
voidBuffer.putShort((short) (firstClusterOffset - written - 4));
|
||||
dump(voidBuffer.array(), out);
|
||||
make_EBML_void(out, CUE_RESERVE_SIZE - cueSize - 7, false);
|
||||
|
||||
seekTo(out, offsetCuesSet);
|
||||
writeInt(out, (int) (cueReservedOffset - baseSegmentOffset));
|
||||
seekTo(out, cueOffset + 5);
|
||||
outByteBuffer.putShort(0, cueSize);
|
||||
dump(outBuffer, DataReader.SHORT_SIZE, out);
|
||||
|
||||
seekTo(out, cueReservedOffset + 5);
|
||||
writeShort(out, cueSize);
|
||||
/* seek head, seek for cues element */
|
||||
writeInt(out, offsetCuesSet, (int) (cueOffset - segmentOffset));
|
||||
|
||||
for (int i = 0; i < clusterSizes.size(); i++) {
|
||||
seekTo(out, clusterOffsets.get(i));
|
||||
byte[] buffer = ByteBuffer.allocate(4).putInt(clusterSizes.get(i) | 0x10000000).array();
|
||||
dump(buffer, out);
|
||||
for (ClusterInfo cluster : clustersOffsetsSizes) {
|
||||
writeInt(out, cluster.offset, cluster.size | 0x10000000);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -375,25 +379,10 @@ public class WebMWriter implements Closeable {
|
|||
written = offset;
|
||||
}
|
||||
|
||||
private void writeLong(SharpStream stream, long number) throws IOException {
|
||||
byte[] buffer = ByteBuffer.allocate(DataReader.LONG_SIZE).putLong(number).array();
|
||||
stream.write(buffer, 1, buffer.length - 1);
|
||||
written += buffer.length - 1;
|
||||
}
|
||||
|
||||
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 writeInt(SharpStream stream, long offset, int number) throws IOException {
|
||||
seekTo(stream, offset);
|
||||
outByteBuffer.putInt(0, number);
|
||||
dump(outBuffer, DataReader.INTEGER_SIZE, stream);
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
for (byte[] buff : listBuffer) {
|
||||
dump(buff, stream);
|
||||
}
|
||||
dump(listBuffer, stream);
|
||||
|
||||
int read;
|
||||
while ((read = bloq.data.read(outBuffer)) > 0) {
|
||||
stream.write(outBuffer, 0, read);
|
||||
written += read;
|
||||
dump(outBuffer, read, stream);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] makeTimecode(long timecode) {
|
||||
ByteBuffer buffer = ByteBuffer.allocate(9);
|
||||
buffer.put((byte) 0xe7);
|
||||
buffer.put(encode(timecode, true));
|
||||
private long makeCluster(SharpStream stream, long timecode, long offset, boolean create) throws IOException {
|
||||
ClusterInfo cluster;
|
||||
|
||||
byte[] res = new byte[buffer.position()];
|
||||
System.arraycopy(buffer.array(), 0, res, 0, res.length);
|
||||
|
||||
return res;
|
||||
if (offset > 0) {
|
||||
// save the size of the previous cluster (maximum 256 MiB)
|
||||
cluster = clustersOffsetsSizes.get(clustersOffsetsSizes.size() - 1);
|
||||
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
|
||||
}
|
||||
offset = written;
|
||||
|
||||
if (clusterOffsets != null) {
|
||||
if (create) {
|
||||
/* cluster */
|
||||
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 {
|
||||
|
@ -509,13 +494,24 @@ public class WebMWriter implements Closeable {
|
|||
buffer.add(new byte[]{(byte) 0x86});
|
||||
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 */
|
||||
buffer.add(new byte[]{(byte) 0x83});
|
||||
buffer.add(encode(track.trackType, true));
|
||||
|
||||
/* default duration */
|
||||
if (track.defaultDuration != 0) {
|
||||
predefinedDurations[internalTrackId] = (int) Math.ceil(track.defaultDuration / (float) DEFAULT_TIMECODE_SCALE);
|
||||
if (track.defaultDuration >= 0) {
|
||||
buffer.add(new byte[]{0x23, (byte) 0xe3, (byte) 0x83});
|
||||
buffer.add(encode(track.defaultDuration, true));
|
||||
}
|
||||
|
@ -538,21 +534,29 @@ public class WebMWriter implements Closeable {
|
|||
|
||||
}
|
||||
|
||||
private ArrayList<byte[]> makeCuePoint(int internalTrackId, KeyFrame keyFrame) {
|
||||
ArrayList<byte[]> buffer = new ArrayList<>(5);
|
||||
private int makeCuePoint(int internalTrackId, KeyFrame keyFrame, byte[] buffer) {
|
||||
ArrayList<byte[]> cue = new ArrayList<>(5);
|
||||
|
||||
/* CuePoint */
|
||||
buffer.add(new byte[]{(byte) 0xbb});
|
||||
buffer.add(null);
|
||||
cue.add(new byte[]{(byte) 0xbb});
|
||||
cue.add(null);
|
||||
|
||||
/* CueTime */
|
||||
buffer.add(new byte[]{(byte) 0xb3});
|
||||
buffer.add(encode(keyFrame.atTimecode, true));
|
||||
cue.add(new byte[]{(byte) 0xb3});
|
||||
cue.add(encode(keyFrame.duration, true));
|
||||
|
||||
/* 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) {
|
||||
|
@ -568,21 +572,49 @@ public class WebMWriter implements Closeable {
|
|||
|
||||
/* CueClusterPosition */
|
||||
buffer.add(new byte[]{(byte) 0xf1});
|
||||
buffer.add(encode(keyFrame.atCluster, true));
|
||||
buffer.add(encode(keyFrame.clusterPosition, true));
|
||||
|
||||
/* CueRelativePosition */
|
||||
if (keyFrame.atBlock > 0) {
|
||||
if (keyFrame.relativePosition > 0) {
|
||||
buffer.add(new byte[]{(byte) 0xf0});
|
||||
buffer.add(encode(keyFrame.atBlock, true));
|
||||
buffer.add(encode(keyFrame.relativePosition, true));
|
||||
}
|
||||
|
||||
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 {
|
||||
dump(buffer, buffer.length, stream);
|
||||
}
|
||||
|
||||
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) {
|
||||
long size = 0;
|
||||
|
@ -614,11 +646,11 @@ public class WebMWriter implements Closeable {
|
|||
byte[] buffer = new byte[offset + length];
|
||||
long marker = (long) Math.floor((length - 1f) / 8f);
|
||||
|
||||
float mul = 1;
|
||||
for (int i = length - 1; i >= 0; i--, mul *= 0x100) {
|
||||
long b = (long) Math.floor(number / mul);
|
||||
int shift = 0;
|
||||
for (int i = length - 1; i >= 0; i--, shift += 8) {
|
||||
long b = number >>> shift;
|
||||
if (!withLength && i == marker) {
|
||||
b = b | (0x80 >> (length - 1));
|
||||
b = b | (0x80 >>> (length - 1));
|
||||
}
|
||||
buffer[offset + i] = (byte) b;
|
||||
}
|
||||
|
@ -686,17 +718,15 @@ public class WebMWriter implements Closeable {
|
|||
|
||||
class KeyFrame {
|
||||
|
||||
KeyFrame(long segment, long cluster, long block, int bTimecodeLength, long timecode) {
|
||||
atCluster = cluster - segment;
|
||||
if ((block - bTimecodeLength) > cluster) {
|
||||
atBlock = (int) (block - cluster);
|
||||
}
|
||||
atTimecode = timecode;
|
||||
KeyFrame(long segment, long cluster, long block, long timecode) {
|
||||
clusterPosition = cluster - segment;
|
||||
relativePosition = (int) (block - cluster - CLUSTER_HEADER_SIZE);
|
||||
duration = timecode;
|
||||
}
|
||||
|
||||
long atCluster;
|
||||
int atBlock;
|
||||
long atTimecode;
|
||||
final long clusterPosition;
|
||||
final int relativePosition;
|
||||
final long duration;
|
||||
}
|
||||
|
||||
class Block {
|
||||
|
@ -717,4 +747,11 @@ public class WebMWriter implements Closeable {
|
|||
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.InfoItem;
|
||||
import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage;
|
||||
import org.schabi.newpipe.extractor.ListInfo;
|
||||
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.comments.CommentsInfo;
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
|
||||
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
||||
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.playlist.PlaylistInfo;
|
||||
import org.schabi.newpipe.extractor.search.SearchInfo;
|
||||
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor;
|
||||
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.UserAction;
|
||||
|
||||
|
@ -131,6 +136,22 @@ public final class ExtractorHelper {
|
|||
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,
|
||||
final String url,
|
||||
boolean forceLoad) {
|
||||
|
|
|
@ -213,6 +213,42 @@ public class Localization {
|
|||
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
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
|
|
@ -23,6 +23,7 @@ import org.schabi.newpipe.MainActivity;
|
|||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.RouterActivity;
|
||||
import org.schabi.newpipe.about.AboutActivity;
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
|
||||
import org.schabi.newpipe.download.DownloadActivity;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
|
@ -110,13 +111,15 @@ public class NavigationHelper {
|
|||
final boolean playbackSkipSilence,
|
||||
@Nullable final String playbackQuality,
|
||||
final boolean resumePlayback,
|
||||
final boolean startPaused) {
|
||||
final boolean startPaused,
|
||||
final boolean isMuted) {
|
||||
return getPlayerIntent(context, targetClazz, playQueue, playbackQuality, resumePlayback)
|
||||
.putExtra(BasePlayer.REPEAT_MODE, repeatMode)
|
||||
.putExtra(BasePlayer.PLAYBACK_SPEED, playbackSpeed)
|
||||
.putExtra(BasePlayer.PLAYBACK_PITCH, playbackPitch)
|
||||
.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) {
|
||||
|
@ -341,9 +344,13 @@ public class NavigationHelper {
|
|||
.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)
|
||||
.replace(R.id.fragment_holder, new FeedFragment())
|
||||
.replace(R.id.fragment_holder, FeedFragment.newInstance(groupId, groupName))
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue