mirror of
https://github.com/TeamNewPipe/NewPipe.git
synced 2025-12-05 01:10:43 +00:00
Compare commits
351 Commits
fc5c8baf90
...
refactor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bca5f3d63 | ||
|
|
40caeb465d | ||
|
|
125fc38ffe | ||
|
|
ebe07596ba | ||
|
|
18f1cf2075 | ||
|
|
03e963952c | ||
|
|
e5ed0b529f | ||
|
|
0131bb227f | ||
|
|
b06b7c35ca | ||
|
|
0a0f28e801 | ||
|
|
4a8592c5ba | ||
|
|
8f91f21f27 | ||
|
|
2ba87f7979 | ||
|
|
f4d138d06f | ||
|
|
6c95c9c7dd | ||
|
|
153e4820e7 | ||
|
|
93f03bab87 | ||
|
|
9702189be4 | ||
|
|
85bd7c3351 | ||
|
|
b7bf07d5e4 | ||
|
|
89a68d0789 | ||
|
|
eaafdb2570 | ||
|
|
f836f5e75d | ||
|
|
2fadaffb98 | ||
|
|
1314a21f71 | ||
|
|
650b51ffec | ||
|
|
c03f405f8c | ||
|
|
0a89276b7a | ||
|
|
300afde83d | ||
|
|
d311faea58 | ||
|
|
71aa6d52d3 | ||
|
|
88eb32be3a | ||
|
|
2a9c6f0538 | ||
|
|
c81148ae0a | ||
|
|
ecd3e85d49 | ||
|
|
c4e6e4d4c4 | ||
|
|
41981902ab | ||
|
|
56a09220ee | ||
|
|
c4bfc119df | ||
|
|
c49e44443c | ||
|
|
1014dd563f | ||
|
|
3516667671 | ||
|
|
22ee01bcfb | ||
|
|
fd4f4737c2 | ||
|
|
6e0b7be15c | ||
|
|
4604339583 | ||
|
|
25e96d584a | ||
|
|
9d3ac1b94f | ||
|
|
d9ddc07d4b | ||
|
|
8856e97c62 | ||
|
|
aed4278388 | ||
|
|
3ab1322425 | ||
|
|
fab0d35269 | ||
|
|
5ba95a2c37 | ||
|
|
2d3a4b67d7 | ||
|
|
9cbcaecb92 | ||
|
|
848b8868fb | ||
|
|
f9222a6a68 | ||
|
|
8292f729ea | ||
|
|
19fb9899cd | ||
|
|
da4878d264 | ||
|
|
abfde872f1 | ||
|
|
c98f56bf7b | ||
|
|
759342fa62 | ||
|
|
0382cfd2ae | ||
|
|
753a3e68d5 | ||
|
|
b6bd87a4dc | ||
|
|
b36201442d | ||
|
|
9d3775f132 | ||
|
|
b2d89a41fb | ||
|
|
01a8c4e584 | ||
|
|
2ee7cc4344 | ||
|
|
2cb465c89d | ||
|
|
ccca89dc8a | ||
|
|
9bf23abcd1 | ||
|
|
10c5a5d02c | ||
|
|
e1888ede87 | ||
|
|
2c35db7a07 | ||
|
|
cf4b5e17c5 | ||
|
|
deb5425871 | ||
|
|
2915ab6aef | ||
|
|
be662a9f1a | ||
|
|
1534c880ef | ||
|
|
ded7205588 | ||
|
|
1f2e5799f7 | ||
|
|
f93806293a | ||
|
|
da36b8a140 | ||
|
|
046ea7301b | ||
|
|
3998982002 | ||
|
|
a4182b474b | ||
|
|
317db719db | ||
|
|
3f62ec7e53 | ||
|
|
7330541499 | ||
|
|
ed0051a3f6 | ||
|
|
91aed1e240 | ||
|
|
38ed1da79e | ||
|
|
cc3ecd4169 | ||
|
|
bf72fd1fa5 | ||
|
|
86b27cf77d | ||
|
|
4fd3ddf392 | ||
|
|
fc7daa96e9 | ||
|
|
9b22773070 | ||
|
|
86063fda6a | ||
|
|
c9be4066f2 | ||
|
|
462f0ad5c0 | ||
|
|
183cc580fc | ||
|
|
52ff052d6a | ||
|
|
31f8dd05a7 | ||
|
|
c2b6c71947 | ||
|
|
bb7873d157 | ||
|
|
e2a02a1f86 | ||
|
|
690af88db9 | ||
|
|
4ffadc2057 | ||
|
|
225cb91105 | ||
|
|
3e106b5e4f | ||
|
|
72b67ab5d4 | ||
|
|
840084d2c9 | ||
|
|
2acfefd0bc | ||
|
|
d81b571d05 | ||
|
|
6efb92a38f | ||
|
|
f16becc872 | ||
|
|
60ea317e61 | ||
|
|
e53f0ff94a | ||
|
|
006b4c9ae1 | ||
|
|
73fef268fc | ||
|
|
cf8fe95abf | ||
|
|
36115c3164 | ||
|
|
be373dca8d | ||
|
|
f5a4af2d67 | ||
|
|
06cf511188 | ||
|
|
26050d808e | ||
|
|
0b32738d42 | ||
|
|
c37db85b97 | ||
|
|
4d6e1a4ecf | ||
|
|
b5dd49ecd3 | ||
|
|
945fbd884b | ||
|
|
545c4f078f | ||
|
|
d4cd54fd7b | ||
|
|
15ab3df511 | ||
|
|
f41b34c090 | ||
|
|
d1954baf29 | ||
|
|
a8da9946d1 | ||
|
|
3d069cdf5b | ||
|
|
eccedc0ab0 | ||
|
|
7cecda5713 | ||
|
|
b6144d01f3 | ||
|
|
df11e53a74 | ||
|
|
1d94fd1582 | ||
|
|
c9542ad6fd | ||
|
|
cbe598182a | ||
|
|
7615f79aca | ||
|
|
47299c9184 | ||
|
|
6486f2de56 | ||
|
|
d805679a5e | ||
|
|
c1bdffd917 | ||
|
|
abaf16e12b | ||
|
|
99aae7eb28 | ||
|
|
fd99c5e461 | ||
|
|
407d2d768d | ||
|
|
b109e4d3cc | ||
|
|
137ade24ff | ||
|
|
48d682016e | ||
|
|
b78e0b2da8 | ||
|
|
32a88ab890 | ||
|
|
af3ed992e5 | ||
|
|
a79516dfff | ||
|
|
dbd11a6a8d | ||
|
|
21973b362a | ||
|
|
60586c90d6 | ||
|
|
095155d35f | ||
|
|
af8e5646a6 | ||
|
|
c9d155a335 | ||
|
|
4e31ccebf8 | ||
|
|
c0965a42a1 | ||
|
|
fc7f1b0af0 | ||
|
|
dfb035dfa5 | ||
|
|
8e9503cfe4 | ||
|
|
82516dd75c | ||
|
|
3e6e980362 | ||
|
|
1890fbb19a | ||
|
|
efb3aa530d | ||
|
|
ce919215fb | ||
|
|
6a4aaba431 | ||
|
|
83d93e16e7 | ||
|
|
8d15a141b1 | ||
|
|
a78bed700a | ||
|
|
ef3c76645f | ||
|
|
d4ed18bf08 | ||
|
|
fbafdeb2ca | ||
|
|
781040efaa | ||
|
|
1547b50b4e | ||
|
|
3f7ef49979 | ||
|
|
dab0148a78 | ||
|
|
c0c08a4f63 | ||
|
|
aaf337421d | ||
|
|
79a0edacd7 | ||
|
|
d56eef6ece | ||
|
|
72f054a4fa | ||
|
|
172c3c92ac | ||
|
|
137ef3fee4 | ||
|
|
e49156fb11 | ||
|
|
de5d45849f | ||
|
|
a25034b898 | ||
|
|
ae9e82b2c1 | ||
|
|
a70b38a8e5 | ||
|
|
08f3dba42c | ||
|
|
0cff3a6ecd | ||
|
|
9b78e49e45 | ||
|
|
e6eea8f851 | ||
|
|
4e55f1bee6 | ||
|
|
cff3834fde | ||
|
|
c8b01a06b0 | ||
|
|
414b1a8344 | ||
|
|
404d9f3fac | ||
|
|
55e4014036 | ||
|
|
1cd5563b27 | ||
|
|
1abced992b | ||
|
|
46b9243661 | ||
|
|
ad72b2cb31 | ||
|
|
8b79d0ee29 | ||
|
|
295f719b77 | ||
|
|
b584353f4d | ||
|
|
d73314b4dd | ||
|
|
9f4a33c7a8 | ||
|
|
3a9540b042 | ||
|
|
ca855cbca0 | ||
|
|
6a98b1dac7 | ||
|
|
7d4a2836fc | ||
|
|
6ea715a18d | ||
|
|
a56debfce6 | ||
|
|
226b6de34f | ||
|
|
13585ca0be | ||
|
|
62ab9bd740 | ||
|
|
fdf36cbad6 | ||
|
|
aea2b7c7f3 | ||
|
|
37d1c784fa | ||
|
|
cea149f852 | ||
|
|
a92a28517e | ||
|
|
800961c3d7 | ||
|
|
9d8a79b0bd | ||
|
|
ef56dea817 | ||
|
|
23b3835af0 | ||
|
|
412e1d602a | ||
|
|
802a094154 | ||
|
|
e6b1341246 | ||
|
|
36ede243e3 | ||
|
|
bac9f7eebf | ||
|
|
8ada566bf1 | ||
|
|
5bd4ed77df | ||
|
|
97652ac015 | ||
|
|
6dd24033a4 | ||
|
|
4de3ef20be | ||
|
|
702f74291d | ||
|
|
d8759993a9 | ||
|
|
7787eafd3a | ||
|
|
4f4136c6e9 | ||
|
|
b399030e19 | ||
|
|
0991461d04 | ||
|
|
49bcf2c41b | ||
|
|
c00c6c460c | ||
|
|
4c4fe3f511 | ||
|
|
db485c3d77 | ||
|
|
c0388d948b | ||
|
|
43bbddcc26 | ||
|
|
6471b64ab6 | ||
|
|
b9fcf0dff8 | ||
|
|
3177ca6e8a | ||
|
|
5017f4f05a | ||
|
|
823d4a041f | ||
|
|
62d4044d6c | ||
|
|
3785404618 | ||
|
|
c98ad62163 | ||
|
|
4cac111b66 | ||
|
|
941b8eb194 | ||
|
|
b1add13bfd | ||
|
|
5fffee2c7d | ||
|
|
f9340ae604 | ||
|
|
d3a6991fd4 | ||
|
|
b0bfd4a807 | ||
|
|
3641698379 | ||
|
|
2836191fb3 | ||
|
|
0df6c7fc2c | ||
|
|
b1ebd3ecd9 | ||
|
|
4758244cf5 | ||
|
|
294b9cf347 | ||
|
|
6d05af484e | ||
|
|
e082bca5e0 | ||
|
|
f9dae9078e | ||
|
|
e955beeef1 | ||
|
|
eaac7f3f85 | ||
|
|
ea414f57d4 | ||
|
|
f984b26626 | ||
|
|
edab9a6a1f | ||
|
|
4740e3be86 | ||
|
|
e639b02fed | ||
|
|
ac1ca1412d | ||
|
|
d131d3399a | ||
|
|
1009dc4d4e | ||
|
|
42cb914616 | ||
|
|
e72da94eb1 | ||
|
|
c5d94a5b60 | ||
|
|
02c5f2607a | ||
|
|
369a46f8fe | ||
|
|
909d214002 | ||
|
|
5e7e14ee4d | ||
|
|
b092fe2c76 | ||
|
|
b9dd7078ad | ||
|
|
93310955f2 | ||
|
|
9c52e039ee | ||
|
|
be037e0756 | ||
|
|
5bfb0449cf | ||
|
|
0ec81c9e52 | ||
|
|
5841eaa6d7 | ||
|
|
e92ba8f5d1 | ||
|
|
1908e18dc4 | ||
|
|
e30d5e4305 | ||
|
|
11bb2495ba | ||
|
|
341cc37ce7 | ||
|
|
1620668966 | ||
|
|
56c80ce6dd | ||
|
|
8ce9a7e43c | ||
|
|
e05d97732e | ||
|
|
644a345b55 | ||
|
|
bda961a04c | ||
|
|
ba2efded76 | ||
|
|
b05b98ca61 | ||
|
|
7a7f81ac7f | ||
|
|
6e6c171dd7 | ||
|
|
8a41c8cf66 | ||
|
|
05271d95a9 | ||
|
|
9d04a73c85 | ||
|
|
d336f4cef2 | ||
|
|
4ec7532126 | ||
|
|
da83646303 | ||
|
|
5062d38b65 | ||
|
|
82b492c050 | ||
|
|
73e3a69aaf | ||
|
|
348a79f91d | ||
|
|
c4ada7ff6e | ||
|
|
39d0691c7e | ||
|
|
71361de8ee | ||
|
|
8aa2590fd3 | ||
|
|
e3b7bf467e | ||
|
|
f74402bc94 | ||
|
|
4d3b4a7b20 | ||
|
|
e6302cc868 | ||
|
|
844b4edf48 | ||
|
|
92a7f22d3c | ||
|
|
03167a1e9c | ||
|
|
d479f29e9b | ||
|
|
1af798b04b |
45
.editorconfig
Normal file
45
.editorconfig
Normal file
@@ -0,0 +1,45 @@
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
|
||||
root = true
|
||||
|
||||
[*.{kt,kts}]
|
||||
ktlint_function_naming_ignore_when_annotated_with = Composable
|
||||
ktlint_standard_annotation = disabled
|
||||
ktlint_standard_argument-list-wrapping = disabled
|
||||
ktlint_standard_backing-property-naming = disabled
|
||||
ktlint_standard_blank-line-before-declaration = disabled
|
||||
ktlint_standard_blank-line-between-when-conditions = disabled
|
||||
ktlint_standard_chain-method-continuation = disabled
|
||||
ktlint_standard_class-signature = disabled
|
||||
ktlint_standard_comment-wrapping = disabled
|
||||
ktlint_standard_enum-wrapping = disabled
|
||||
ktlint_standard_function-expression-body = disabled
|
||||
ktlint_standard_function-literal = disabled
|
||||
ktlint_standard_function-signature = disabled
|
||||
ktlint_standard_indent = disabled
|
||||
ktlint_standard_kdoc = disabled
|
||||
ktlint_standard_max-line-length = disabled
|
||||
ktlint_standard_mixed-condition-operators = disabled
|
||||
ktlint_standard_multiline-expression-wrapping = disabled
|
||||
ktlint_standard_multiline-if-else = disabled
|
||||
ktlint_standard_no-blank-line-in-list = disabled
|
||||
ktlint_standard_no-consecutive-comments = disabled
|
||||
ktlint_standard_no-empty-first-line-in-class-body = disabled
|
||||
ktlint_standard_no-empty-first-line-in-method-block = disabled
|
||||
ktlint_standard_no-line-break-after-else = disabled
|
||||
ktlint_standard_no-semi = disabled
|
||||
ktlint_standard_no-single-line-block-comment = disabled
|
||||
ktlint_standard_package-name = disabled
|
||||
ktlint_standard_parameter-list-wrapping = disabled
|
||||
ktlint_standard_property-naming = disabled
|
||||
ktlint_standard_spacing-between-declarations-with-annotations = disabled
|
||||
ktlint_standard_spacing-between-declarations-with-comments = disabled
|
||||
ktlint_standard_statement-wrapping = disabled
|
||||
ktlint_standard_string-template-indent = disabled
|
||||
ktlint_standard_trailing-comma-on-call-site = disabled
|
||||
ktlint_standard_trailing-comma-on-declaration-site = disabled
|
||||
ktlint_standard_try-catch-finally-spacing = disabled
|
||||
ktlint_standard_when-entry-bracing = disabled
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -2,7 +2,7 @@
|
||||
|
||||
#### What is it?
|
||||
- [ ] Bugfix (user facing)
|
||||
- [ ] Feature (user facing)
|
||||
- [ ] Feature (user facing) ⚠️ **Your PR must target the [`refactor`](https://github.com/TeamNewPipe/NewPipe/tree/refactor) branch**
|
||||
- [ ] Codebase improvement (dev facing)
|
||||
- [ ] Meta improvement to the project (dev facing)
|
||||
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -7,9 +7,11 @@ captures/
|
||||
*.iml
|
||||
*~
|
||||
.weblate
|
||||
.kotlin
|
||||
*.class
|
||||
app/debug/
|
||||
app/release/
|
||||
.kotlin/
|
||||
|
||||
# vscode / eclipse files
|
||||
*.classpath
|
||||
@@ -18,3 +20,6 @@ app/release/
|
||||
bin/
|
||||
.vscode/
|
||||
*.code-workspace
|
||||
|
||||
# logs
|
||||
*.log
|
||||
|
||||
@@ -2,18 +2,22 @@
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.jetbrains.kotlin.android)
|
||||
alias(libs.plugins.google.ksp)
|
||||
alias(libs.plugins.jetbrains.kotlin.compose)
|
||||
alias(libs.plugins.jetbrains.kotlin.kapt)
|
||||
alias(libs.plugins.jetbrains.kotlin.parcelize)
|
||||
alias(libs.plugins.jetbrains.kotlinx.serialization)
|
||||
alias(libs.plugins.google.ksp)
|
||||
alias(libs.plugins.sonarqube)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.about.libraries)
|
||||
checkstyle
|
||||
}
|
||||
|
||||
apply(from = "check-dependencies.gradle.kts")
|
||||
|
||||
val gitWorkingBranch = providers.exec {
|
||||
commandLine("git", "rev-parse", "--abbrev-ref", "HEAD")
|
||||
}.standardOutput.asText.map { it.trim() }
|
||||
@@ -24,6 +28,15 @@ java {
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
// TODO: Drop annotation default target when it is stable
|
||||
freeCompilerArgs.addAll(
|
||||
"-Xannotation-default-target=param-property"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 36
|
||||
namespace = "org.schabi.newpipe"
|
||||
@@ -102,6 +115,7 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
@@ -159,7 +173,7 @@ tasks.register<JavaExec>("runKtlint") {
|
||||
outputs.dir(outputDir)
|
||||
mainClass.set("com.pinterest.ktlint.Main")
|
||||
classpath = configurations.getByName("ktlint")
|
||||
args = listOf("src/**/*.kt")
|
||||
args = listOf("--editorconfig=../.editorconfig", "src/**/*.kt")
|
||||
jvmArgs = listOf("--add-opens", "java.base/java.lang=ALL-UNNAMED")
|
||||
}
|
||||
|
||||
@@ -168,10 +182,14 @@ tasks.register<JavaExec>("formatKtlint") {
|
||||
outputs.dir(outputDir)
|
||||
mainClass.set("com.pinterest.ktlint.Main")
|
||||
classpath = configurations.getByName("ktlint")
|
||||
args = listOf("-F", "src/**/*.kt")
|
||||
args = listOf("--editorconfig=../.editorconfig", "-F", "src/**/*.kt")
|
||||
jvmArgs = listOf("--add-opens", "java.base/java.lang=ALL-UNNAMED")
|
||||
}
|
||||
|
||||
tasks.register<CheckDependenciesOrder>("checkDependenciesOrder") {
|
||||
tomlFile = layout.projectDirectory.file("../gradle/libs.versions.toml")
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
tasks.named("preDebugBuild").configure {
|
||||
if (!System.getProperties().containsKey("skipFormatKtlint")) {
|
||||
@@ -189,6 +207,13 @@ sonar {
|
||||
}
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
// note: offline mode prevents the plugin from fetching licenses at build time, which would be
|
||||
// harmful for reproducible builds
|
||||
offlineMode = true
|
||||
duplicationMode = DuplicateMode.MERGE
|
||||
}
|
||||
|
||||
dependencies {
|
||||
/** Desugaring **/
|
||||
coreLibraryDesugaring(libs.android.desugar)
|
||||
@@ -202,16 +227,18 @@ dependencies {
|
||||
checkstyle(libs.puppycrawl.checkstyle)
|
||||
ktlint(libs.pinterest.ktlint)
|
||||
|
||||
/** Kotlin **/
|
||||
implementation(libs.kotlin.stdlib)
|
||||
|
||||
/** AndroidX **/
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.cardview)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.documentfile)
|
||||
implementation(libs.androidx.fragment)
|
||||
implementation(libs.androidx.fragment.compose)
|
||||
implementation(libs.androidx.lifecycle.livedata)
|
||||
implementation(libs.androidx.lifecycle.viewmodel)
|
||||
implementation(libs.androidx.localbroadcastmanager)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.androidx.media)
|
||||
implementation(libs.androidx.preference)
|
||||
implementation(libs.androidx.recyclerview)
|
||||
@@ -219,16 +246,47 @@ dependencies {
|
||||
implementation(libs.androidx.room.rxjava3)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.androidx.swiperefreshlayout)
|
||||
implementation(libs.androidx.viewpager2)
|
||||
implementation(libs.androidx.work.runtime)
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(libs.androidx.work.rxjava3)
|
||||
implementation(libs.google.android.material)
|
||||
implementation(libs.androidx.webkit)
|
||||
|
||||
/** Compose & other modern patterns **/
|
||||
// Jetpack Compose
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.adaptive)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.compose.ui.text) // Needed for parsing HTML to AnnotatedString
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
|
||||
// Jetpack Compose related dependencies
|
||||
implementation(libs.androidx.paging.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
// Coroutines interop
|
||||
implementation(libs.kotlinx.coroutines.rx3)
|
||||
|
||||
// Library loading for About screen
|
||||
implementation(libs.about.libraries.compose.m3)
|
||||
|
||||
// Hilt
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
// Scroll
|
||||
implementation(libs.lazy.column.scrollbar)
|
||||
|
||||
// Kotlinx Serialization
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
/** Third-party libraries **/
|
||||
// Instance state boilerplate elimination
|
||||
implementation(libs.livefront.bridge)
|
||||
implementation(libs.evernote.statesaver.core)
|
||||
ksp(libs.evernote.statesaver.compiler)
|
||||
kapt(libs.evernote.statesaver.compiler)
|
||||
|
||||
// HTML parser
|
||||
implementation(libs.jsoup)
|
||||
@@ -255,7 +313,8 @@ dependencies {
|
||||
implementation(libs.lisawray.groupie.viewbinding)
|
||||
|
||||
// Image loading
|
||||
implementation(libs.squareup.picasso)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
// Markdown library for Android
|
||||
implementation(libs.noties.markwon.core)
|
||||
@@ -285,6 +344,9 @@ dependencies {
|
||||
debugImplementation(libs.facebook.stetho.core)
|
||||
debugImplementation(libs.facebook.stetho.okhttp3)
|
||||
|
||||
// Jetpack Compose
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
/** Testing **/
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockito.core)
|
||||
@@ -293,4 +355,7 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.runner)
|
||||
androidTestImplementation(libs.androidx.room.testing)
|
||||
androidTestImplementation(libs.assertj.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
|
||||
48
app/check-dependencies.gradle
Normal file
48
app/check-dependencies.gradle
Normal file
@@ -0,0 +1,48 @@
|
||||
tasks.register('checkDependenciesOrder') {
|
||||
group = 'verification'
|
||||
description = 'Checks that each section in libs.versions.toml is sorted alphabetically'
|
||||
|
||||
def tomlFile = file('../gradle/libs.versions.toml')
|
||||
|
||||
doLast {
|
||||
if (!tomlFile.exists()) {
|
||||
throw new GradleException('TOML file not found')
|
||||
}
|
||||
|
||||
def lines = tomlFile.readLines()
|
||||
def nonSortedBlocks = []
|
||||
def currentBlock = []
|
||||
def prevLine = ''
|
||||
def prevIndex = 0
|
||||
|
||||
lines.eachWithIndex { line, lineIndex ->
|
||||
if (line.trim() && !line.startsWith('#')) {
|
||||
if (line.startsWith('[')) {
|
||||
prevLine = ''
|
||||
} else {
|
||||
def currIndex = lineIndex + 1
|
||||
if (prevLine > line) {
|
||||
if (currentBlock && currentBlock[-1] == "${prevIndex}: ${prevLine}") {
|
||||
currentBlock.add("${currIndex}: ${line}")
|
||||
} else {
|
||||
if (!currentBlock.isEmpty()) {
|
||||
nonSortedBlocks.add(currentBlock)
|
||||
currentBlock = []
|
||||
}
|
||||
currentBlock.add("${prevIndex}: ${prevLine}")
|
||||
currentBlock.add("${currIndex}: ${line}")
|
||||
}
|
||||
}
|
||||
prevLine = line
|
||||
prevIndex = lineIndex + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentBlock.isEmpty()) {
|
||||
nonSortedBlocks.add(currentBlock)
|
||||
throw new GradleException("The following lines were not sorted:\n" +
|
||||
nonSortedBlocks.collect { it.join("\n") }.join("\n\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
15
app/proguard-rules.pro
vendored
15
app/proguard-rules.pro
vendored
@@ -34,3 +34,18 @@
|
||||
|
||||
## For some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml)
|
||||
-keep class org.schabi.newpipe.settings.notifications.** { *; }
|
||||
|
||||
## Keep Kotlinx Serialization classes
|
||||
-keepclassmembers class kotlinx.serialization.json.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class kotlinx.serialization.json.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
-keep,includedescriptorclasses class org.schabi.newpipe.**$$serializer { *; }
|
||||
-keepclassmembers class org.schabi.newpipe.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class org.schabi.newpipe.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
package org.schabi.newpipe.error;
|
||||
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.filters.LargeTest;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.ServiceList;
|
||||
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* Instrumented tests for {@link ErrorInfo}.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
@LargeTest
|
||||
public class ErrorInfoTest {
|
||||
|
||||
/**
|
||||
* @param errorInfo the error info to access
|
||||
* @return the private field errorInfo.message.stringRes using reflection
|
||||
*/
|
||||
private int getMessageFromErrorInfo(final ErrorInfo errorInfo)
|
||||
throws NoSuchFieldException, IllegalAccessException {
|
||||
final var message = ErrorInfo.class.getDeclaredField("message");
|
||||
message.setAccessible(true);
|
||||
final var messageValue = (ErrorInfo.Companion.ErrorMessage) message.get(errorInfo);
|
||||
|
||||
final var stringRes = ErrorInfo.Companion.ErrorMessage.class.getDeclaredField("stringRes");
|
||||
stringRes.setAccessible(true);
|
||||
return (int) Objects.requireNonNull(stringRes.get(messageValue));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void errorInfoTestParcelable() throws NoSuchFieldException, IllegalAccessException {
|
||||
final ErrorInfo info = new ErrorInfo(new ParsingException("Hello"),
|
||||
UserAction.USER_REPORT, "request", ServiceList.YouTube.getServiceId());
|
||||
// Obtain a Parcel object and write the parcelable object to it:
|
||||
final Parcel parcel = Parcel.obtain();
|
||||
info.writeToParcel(parcel, 0);
|
||||
parcel.setDataPosition(0);
|
||||
final ErrorInfo infoFromParcel = (ErrorInfo) ErrorInfo.CREATOR.createFromParcel(parcel);
|
||||
|
||||
assertTrue(Arrays.toString(infoFromParcel.getStackTraces())
|
||||
.contains(ErrorInfoTest.class.getSimpleName()));
|
||||
assertEquals(UserAction.USER_REPORT, infoFromParcel.getUserAction());
|
||||
assertEquals(ServiceList.YouTube.getServiceInfo().getName(),
|
||||
infoFromParcel.getServiceName());
|
||||
assertEquals("request", infoFromParcel.getRequest());
|
||||
assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel));
|
||||
|
||||
parcel.recycle();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package org.schabi.newpipe.error
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.LargeTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.ServiceList
|
||||
import org.schabi.newpipe.extractor.exceptions.ParsingException
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||
import java.io.IOException
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
/**
|
||||
* Instrumented tests for {@link ErrorInfo}.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@LargeTest
|
||||
class ErrorInfoTest {
|
||||
private val context: Context by lazy { ApplicationProvider.getApplicationContext<Context>() }
|
||||
|
||||
/**
|
||||
* @param errorInfo the error info to access
|
||||
* @return the private field errorInfo.message.stringRes using reflection
|
||||
*/
|
||||
@Throws(NoSuchFieldException::class, IllegalAccessException::class)
|
||||
private fun getMessageFromErrorInfo(errorInfo: ErrorInfo): Int {
|
||||
val message = ErrorInfo::class.java.getDeclaredField("message")
|
||||
message.isAccessible = true
|
||||
val messageValue = message.get(errorInfo) as ErrorInfo.Companion.ErrorMessage
|
||||
|
||||
val stringRes = ErrorInfo.Companion.ErrorMessage::class.java.getDeclaredField("stringRes")
|
||||
stringRes.isAccessible = true
|
||||
return stringRes.get(messageValue) as Int
|
||||
}
|
||||
|
||||
@Test
|
||||
@Throws(NoSuchFieldException::class, IllegalAccessException::class)
|
||||
fun errorInfoTestParcelable() {
|
||||
val info = ErrorInfo(
|
||||
ParsingException("Hello"),
|
||||
UserAction.USER_REPORT,
|
||||
"request",
|
||||
ServiceList.YouTube.serviceId
|
||||
)
|
||||
// Obtain a Parcel object and write the parcelable object to it:
|
||||
val parcel = Parcel.obtain()
|
||||
info.writeToParcel(parcel, 0)
|
||||
parcel.setDataPosition(0)
|
||||
val creatorField = ErrorInfo::class.java.getDeclaredField("CREATOR")
|
||||
val creator = creatorField.get(null)
|
||||
check(creator is Parcelable.Creator<*>)
|
||||
val infoFromParcel = requireNotNull(
|
||||
creator.createFromParcel(parcel) as? ErrorInfo
|
||||
)
|
||||
assertTrue(
|
||||
infoFromParcel.stackTraces.contentToString()
|
||||
.contains(ErrorInfoTest::class.java.simpleName)
|
||||
)
|
||||
assertEquals(UserAction.USER_REPORT, infoFromParcel.userAction)
|
||||
assertEquals(
|
||||
ServiceList.YouTube.serviceInfo.name,
|
||||
infoFromParcel.getServiceName()
|
||||
)
|
||||
assertEquals("request", infoFromParcel.request)
|
||||
assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel))
|
||||
|
||||
parcel.recycle()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Network error on initial load (Resource.Error)
|
||||
*/
|
||||
|
||||
@Test
|
||||
fun testInitialCommentNetworkError() {
|
||||
val errorInfo = ErrorInfo(
|
||||
throwable = SocketTimeoutException("Connection timeout"),
|
||||
userAction = UserAction.REQUESTED_COMMENTS,
|
||||
request = "comments"
|
||||
)
|
||||
assertEquals(context.getString(R.string.network_error), errorInfo.getMessage(context))
|
||||
assertTrue(errorInfo.isReportable)
|
||||
assertTrue(errorInfo.isRetryable)
|
||||
assertNull(errorInfo.recaptchaUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: Network error on paging (LoadState.Error)
|
||||
*/
|
||||
@Test
|
||||
fun testPagingNetworkError() {
|
||||
val errorInfo = ErrorInfo(
|
||||
throwable = IOException("Paging failed"),
|
||||
userAction = UserAction.REQUESTED_COMMENTS,
|
||||
request = "comments"
|
||||
)
|
||||
assertEquals(context.getString(R.string.network_error), errorInfo.getMessage(context))
|
||||
assertTrue(errorInfo.isReportable)
|
||||
assertTrue(errorInfo.isRetryable)
|
||||
assertNull(errorInfo.recaptchaUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Test: ReCaptcha during comments load
|
||||
*/
|
||||
@Test
|
||||
fun testReCaptchaDuringComments() {
|
||||
val url = "https://www.google.com/recaptcha/api/fallback?k=test"
|
||||
val errorInfo = ErrorInfo(
|
||||
throwable = ReCaptchaException("ReCaptcha needed", url),
|
||||
userAction = UserAction.REQUESTED_COMMENTS,
|
||||
request = "comments"
|
||||
)
|
||||
assertEquals(context.getString(R.string.recaptcha_request_toast), errorInfo.getMessage(context))
|
||||
assertEquals(url, errorInfo.recaptchaUrl)
|
||||
assertFalse(errorInfo.isReportable)
|
||||
assertTrue(errorInfo.isRetryable)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package org.schabi.newpipe.ui.components.common
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.error.ErrorInfo
|
||||
import org.schabi.newpipe.error.UserAction
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||
import org.schabi.newpipe.ui.theme.AppTheme
|
||||
import java.net.UnknownHostException
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ErrorPanelTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun setErrorPanel(errorInfo: ErrorInfo, onRetry: (() -> Unit)? = null) {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
ErrorPanel(errorInfo = errorInfo, onRetry = onRetry)
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun text(@StringRes id: Int) = composeRule.activity.getString(id)
|
||||
|
||||
/**
|
||||
* Test Network Error
|
||||
*/
|
||||
@Test
|
||||
fun testNetworkErrorShowsRetryWithoutReportButton() {
|
||||
val networkErrorInfo = ErrorInfo(
|
||||
throwable = UnknownHostException("offline"),
|
||||
userAction = UserAction.REQUESTED_STREAM,
|
||||
request = "https://example.com/watch?v=foo"
|
||||
)
|
||||
|
||||
setErrorPanel(networkErrorInfo, onRetry = {})
|
||||
composeRule.onNodeWithText(text(R.string.network_error)).assertIsDisplayed()
|
||||
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true).assertIsDisplayed()
|
||||
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
|
||||
.assertDoesNotExist()
|
||||
composeRule.onNodeWithText(text(R.string.recaptcha_solve), ignoreCase = true)
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Unexpected Error, Shows Report and Retry buttons
|
||||
*/
|
||||
@Test
|
||||
fun unexpectedErrorShowsReportAndRetryButtons() {
|
||||
val unexpectedErrorInfo = ErrorInfo(
|
||||
throwable = RuntimeException("Unexpected error"),
|
||||
userAction = UserAction.REQUESTED_STREAM,
|
||||
request = "https://example.com/watch?v=bar"
|
||||
)
|
||||
|
||||
setErrorPanel(unexpectedErrorInfo, onRetry = {})
|
||||
composeRule.onNodeWithText(text(R.string.error_snackbar_message)).assertIsDisplayed()
|
||||
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true).assertIsDisplayed()
|
||||
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Recaptcha Error shows solve, retry and open in browser buttons
|
||||
*/
|
||||
@Test
|
||||
fun recaptchaErrorShowsSolveAndRetryOpenInBrowserButtons() {
|
||||
var retryClicked = false
|
||||
val recaptchaErrorInfo = ErrorInfo(
|
||||
throwable = ReCaptchaException(
|
||||
"Recaptcha required",
|
||||
"https://example.com/captcha"
|
||||
),
|
||||
userAction = UserAction.REQUESTED_STREAM,
|
||||
request = "https://example.com/watch?v=baz",
|
||||
openInBrowserUrl = "https://example.com/watch?v=baz"
|
||||
)
|
||||
|
||||
setErrorPanel(
|
||||
errorInfo = recaptchaErrorInfo,
|
||||
onRetry = { retryClicked = true }
|
||||
|
||||
)
|
||||
composeRule.onNodeWithText(text(R.string.recaptcha_solve), ignoreCase = true)
|
||||
.assertIsDisplayed()
|
||||
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true)
|
||||
.assertIsDisplayed()
|
||||
.performClick()
|
||||
composeRule.onNodeWithText(text(R.string.open_in_browser), ignoreCase = true)
|
||||
.assertIsDisplayed()
|
||||
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
|
||||
.assertDoesNotExist()
|
||||
assert(retryClicked) { "onRetry callback should have been invoked" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Content Not Available Error hides retry button
|
||||
*/
|
||||
@Test
|
||||
fun testNonRetryableErrorHidesRetryAndReportButtons() {
|
||||
val contentNotAvailable = ErrorInfo(
|
||||
throwable = ContentNotAvailableException("Video has been removed"),
|
||||
userAction = UserAction.REQUESTED_STREAM,
|
||||
request = "https://example.com/watch?v=qux"
|
||||
)
|
||||
|
||||
setErrorPanel(contentNotAvailable)
|
||||
|
||||
composeRule.onNodeWithText(text(R.string.content_not_available))
|
||||
.assertIsDisplayed()
|
||||
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true)
|
||||
.assertDoesNotExist()
|
||||
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package org.schabi.newpipe.ui.components.video.comment
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.LoadStates
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.error.ErrorInfo
|
||||
import org.schabi.newpipe.error.UserAction
|
||||
import org.schabi.newpipe.extractor.Page
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||
import org.schabi.newpipe.extractor.stream.Description
|
||||
import org.schabi.newpipe.ui.components.common.ErrorPanel
|
||||
import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar
|
||||
import org.schabi.newpipe.ui.components.common.LoadingIndicator
|
||||
import org.schabi.newpipe.ui.emptystate.EmptyStateComposable
|
||||
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec
|
||||
import org.schabi.newpipe.ui.theme.AppTheme
|
||||
import org.schabi.newpipe.viewmodels.util.Resource
|
||||
import java.net.UnknownHostException
|
||||
|
||||
class CommentSectionInstrumentedTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<androidx.activity.ComponentActivity>()
|
||||
|
||||
private val uiStateFlow = MutableStateFlow<Resource<CommentInfo>>(Resource.Loading)
|
||||
private val pagingFlow = MutableStateFlow(PagingData.empty<CommentsInfoItem>())
|
||||
private fun string(@StringRes resId: Int) = composeRule.activity.getString(resId)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
composeRule.setContent {
|
||||
AppTheme {
|
||||
TestCommentSection(uiStateFlow = uiStateFlow, commentsFlow = pagingFlow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun successState(commentCount: Int) = Resource.Success(
|
||||
CommentInfo(
|
||||
serviceId = 0,
|
||||
url = "",
|
||||
comments = emptyList(),
|
||||
nextPage = null,
|
||||
commentCount = commentCount,
|
||||
isCommentsDisabled = false
|
||||
)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun commentListLoadsAndScrolls() {
|
||||
val comments = (1..25).map { index ->
|
||||
CommentsInfoItem(
|
||||
commentText = Description("Comment $index", Description.PLAIN_TEXT),
|
||||
uploaderName = "Uploader $index",
|
||||
replies = Page(""),
|
||||
replyCount = 0
|
||||
)
|
||||
}
|
||||
uiStateFlow.value = successState(comments.size)
|
||||
pagingFlow.value = PagingData.from(comments)
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText("Comment 1").assertIsDisplayed()
|
||||
composeRule.onNodeWithTag("comment_list")
|
||||
.performScrollToNode(hasText("Comment 25"))
|
||||
composeRule.onNodeWithText("Comment 25").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
@Test
|
||||
fun pagingErrorShowsErrorPanelAndAllowsRetry() {
|
||||
uiStateFlow.value = successState(10)
|
||||
pagingFlow.value = PagingData.from(
|
||||
data = emptyList(),
|
||||
sourceLoadStates = LoadStates(
|
||||
refresh = LoadState.Error(ReCaptchaException("captcha required", "https://example.com")),
|
||||
prepend = LoadState.NotLoading(true),
|
||||
append = LoadState.NotLoading(true)
|
||||
)
|
||||
)
|
||||
composeRule.waitForIdle()
|
||||
|
||||
val solveMatcher = hasText(string(R.string.recaptcha_solve), ignoreCase = true)
|
||||
.and(hasClickAction())
|
||||
val retryMatcher = hasText(string(R.string.retry), ignoreCase = true)
|
||||
.and(hasClickAction())
|
||||
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodes(solveMatcher).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodes(retryMatcher).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
composeRule.onNode(retryMatcher)
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
val recoveredComment = CommentsInfoItem(
|
||||
commentText = Description("Recovered comment", Description.PLAIN_TEXT),
|
||||
uploaderName = "Uploader",
|
||||
replies = Page(""),
|
||||
replyCount = 0
|
||||
)
|
||||
|
||||
uiStateFlow.value = successState(1)
|
||||
pagingFlow.value = PagingData.from(
|
||||
data = listOf(recoveredComment),
|
||||
sourceLoadStates = LoadStates(
|
||||
refresh = LoadState.NotLoading(false),
|
||||
prepend = LoadState.NotLoading(true),
|
||||
append = LoadState.NotLoading(true)
|
||||
)
|
||||
)
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodes(hasText("Recovered comment"))
|
||||
.fetchSemanticsNodes()
|
||||
.isNotEmpty()
|
||||
}
|
||||
composeRule.onNodeWithText("Recovered comment").assertIsDisplayed()
|
||||
|
||||
composeRule.onNode(solveMatcher).assertDoesNotExist()
|
||||
composeRule.onNode(retryMatcher).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
@Test
|
||||
fun resourceErrorShowsErrorPanelAndRetry() {
|
||||
uiStateFlow.value = Resource.Error(UnknownHostException("offline"))
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithText(string(R.string.network_error)).assertIsDisplayed()
|
||||
val retryMatcher = hasText(string(R.string.retry), ignoreCase = true)
|
||||
.and(hasClickAction())
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodes(retryMatcher).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
composeRule.onNode(retryMatcher)
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
val recoveredComment = CommentsInfoItem(
|
||||
commentText = Description("Recovered comment", Description.PLAIN_TEXT),
|
||||
uploaderName = "Uploader",
|
||||
replies = Page(""),
|
||||
replyCount = 0
|
||||
)
|
||||
|
||||
uiStateFlow.value = successState(1)
|
||||
pagingFlow.value = PagingData.from(
|
||||
data = listOf(recoveredComment),
|
||||
sourceLoadStates = LoadStates(
|
||||
refresh = LoadState.NotLoading(false),
|
||||
prepend = LoadState.NotLoading(true),
|
||||
append = LoadState.NotLoading(true)
|
||||
)
|
||||
)
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodes(hasText("Recovered comment"))
|
||||
.fetchSemanticsNodes()
|
||||
.isNotEmpty()
|
||||
}
|
||||
composeRule.onNodeWithText("Recovered comment").assertIsDisplayed()
|
||||
|
||||
composeRule.onNodeWithText(string(R.string.network_error))
|
||||
.assertDoesNotExist()
|
||||
composeRule.onNode(retryMatcher).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
@Test
|
||||
fun retryAfterErrorRecoversList() {
|
||||
uiStateFlow.value = Resource.Error(RuntimeException("boom"))
|
||||
composeRule.waitForIdle()
|
||||
|
||||
val retryMatcher = hasText(string(R.string.retry), ignoreCase = true)
|
||||
.and(hasClickAction())
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodes(retryMatcher).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
composeRule.onNode(retryMatcher)
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
val firstComment = CommentsInfoItem(
|
||||
commentText = Description("First comment", Description.PLAIN_TEXT),
|
||||
uploaderName = "Uploader",
|
||||
replies = Page(""),
|
||||
replyCount = 0
|
||||
)
|
||||
|
||||
uiStateFlow.value = successState(1)
|
||||
pagingFlow.value = PagingData.from(
|
||||
data = listOf(firstComment),
|
||||
sourceLoadStates = LoadStates(
|
||||
refresh = LoadState.NotLoading(false),
|
||||
prepend = LoadState.NotLoading(true),
|
||||
append = LoadState.NotLoading(true)
|
||||
)
|
||||
)
|
||||
composeRule.waitForIdle()
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
||||
composeRule.onAllNodes(hasText("First comment"))
|
||||
.fetchSemanticsNodes()
|
||||
.isNotEmpty()
|
||||
}
|
||||
composeRule.onNodeWithText("First comment").assertIsDisplayed()
|
||||
|
||||
composeRule.onNodeWithText(string(R.string.network_error))
|
||||
.assertDoesNotExist()
|
||||
composeRule.onNode(retryMatcher).assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TestCommentSection(
|
||||
uiStateFlow: StateFlow<Resource<CommentInfo>>,
|
||||
commentsFlow: Flow<PagingData<CommentsInfoItem>>
|
||||
) {
|
||||
val uiState by uiStateFlow.collectAsState()
|
||||
val comments = commentsFlow.collectAsLazyPagingItems()
|
||||
val nestedScrollInterop = rememberNestedScrollInteropConnection()
|
||||
val listState = rememberLazyListState()
|
||||
val COMMENT_LIST_TAG = "comment_list"
|
||||
|
||||
LazyColumnThemedScrollbar(state = listState) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.testTag(COMMENT_LIST_TAG)
|
||||
.nestedScroll(nestedScrollInterop),
|
||||
state = listState
|
||||
) {
|
||||
when (uiState) {
|
||||
is Resource.Loading -> item {
|
||||
LoadingIndicator(modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
is Resource.Success -> {
|
||||
val commentInfo = (uiState as Resource.Success<CommentInfo>).data
|
||||
val count = commentInfo.commentCount
|
||||
|
||||
when {
|
||||
commentInfo.isCommentsDisabled -> item {
|
||||
EmptyStateComposable(
|
||||
spec = EmptyStateSpec.DisabledComments,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp)
|
||||
)
|
||||
}
|
||||
count == 0 -> item {
|
||||
EmptyStateComposable(
|
||||
spec = EmptyStateSpec.NoComments,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp)
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
if (count >= 0) {
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp, end = 12.dp, bottom = 4.dp),
|
||||
text = pluralStringResource(R.plurals.comments, count, count),
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
when (val refresh = comments.loadState.refresh) {
|
||||
is LoadState.Loading -> item {
|
||||
LoadingIndicator(modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
is LoadState.Error -> item {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
ErrorPanel(
|
||||
errorInfo = ErrorInfo(
|
||||
throwable = refresh.error,
|
||||
userAction = UserAction.REQUESTED_COMMENTS,
|
||||
request = "comments"
|
||||
),
|
||||
onRetry = { comments.retry() },
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> items(comments.itemCount) { index ->
|
||||
Comment(comment = comments[index]!!) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is Resource.Error -> item {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
ErrorPanel(
|
||||
errorInfo = ErrorInfo(
|
||||
throwable = (uiState as Resource.Error).throwable,
|
||||
userAction = UserAction.REQUESTED_COMMENTS,
|
||||
request = "comments"
|
||||
),
|
||||
onRetry = { comments.retry() },
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,28 +91,25 @@
|
||||
android:exported="false"
|
||||
android:label="@string/settings" />
|
||||
|
||||
<activity
|
||||
android:name=".settings.SettingsV2Activity"
|
||||
android:exported="true"
|
||||
android:label="@string/settings" />
|
||||
|
||||
<activity
|
||||
android:name=".about.AboutActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/title_activity_about" />
|
||||
|
||||
<service
|
||||
android:name=".local.subscription.services.SubscriptionsImportService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name=".local.subscription.services.SubscriptionsExportService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
|
||||
<service
|
||||
android:name=".local.feed.service.FeedLoadService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
|
||||
<activity
|
||||
android:name=".PanicResponderActivity"
|
||||
android:exported="true"
|
||||
@@ -143,7 +140,8 @@
|
||||
android:label="@string/app_name"
|
||||
android:launchMode="singleTask" />
|
||||
|
||||
<service android:name="us.shandian.giga.service.DownloadManagerService"
|
||||
<service
|
||||
android:name="us.shandian.giga.service.DownloadManagerService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<activity
|
||||
@@ -340,6 +338,7 @@
|
||||
<data android:scheme="https" />
|
||||
<data android:host="soundcloud.com" />
|
||||
<data android:host="m.soundcloud.com" />
|
||||
<data android:host="on.soundcloud.com" />
|
||||
<data android:host="www.soundcloud.com" />
|
||||
<data android:pathPrefix="/" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
package org.schabi.newpipe;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.app.NotificationChannelCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import com.jakewharton.processphoenix.ProcessPhoenix;
|
||||
|
||||
import org.acra.ACRA;
|
||||
import org.acra.config.CoreConfigurationBuilder;
|
||||
import org.schabi.newpipe.error.ReCaptchaActivity;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.downloader.Downloader;
|
||||
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor;
|
||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||
import org.schabi.newpipe.settings.NewPipeSettings;
|
||||
import org.schabi.newpipe.util.BridgeStateSaverInitializer;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
import org.schabi.newpipe.util.StateSaver;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.image.PreferredImageQuality;
|
||||
import org.schabi.newpipe.util.potoken.PoTokenProviderImpl;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InterruptedIOException;
|
||||
import java.net.SocketException;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import io.reactivex.rxjava3.exceptions.CompositeException;
|
||||
import io.reactivex.rxjava3.exceptions.MissingBackpressureException;
|
||||
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException;
|
||||
import io.reactivex.rxjava3.exceptions.UndeliverableException;
|
||||
import io.reactivex.rxjava3.functions.Consumer;
|
||||
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
|
||||
|
||||
/*
|
||||
* Copyright (C) Hans-Christoph Steiner 2016 <hans@eds.org>
|
||||
* App.java is part of NewPipe.
|
||||
*
|
||||
* NewPipe 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.
|
||||
*
|
||||
* NewPipe 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 NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
public class App extends Application {
|
||||
public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID;
|
||||
private static final String TAG = App.class.toString();
|
||||
|
||||
private boolean isFirstRun = false;
|
||||
private static App app;
|
||||
|
||||
@NonNull
|
||||
public static App getApp() {
|
||||
return app;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(final Context base) {
|
||||
super.attachBaseContext(base);
|
||||
initACRA();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
app = this;
|
||||
|
||||
if (ProcessPhoenix.isPhoenixProcess(this)) {
|
||||
Log.i(TAG, "This is a phoenix process! "
|
||||
+ "Aborting initialization of App[onCreate]");
|
||||
return;
|
||||
}
|
||||
|
||||
// check if the last used preference version is set
|
||||
// to determine whether this is the first app run
|
||||
final int lastUsedPrefVersion = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
.getInt(getString(R.string.last_used_preferences_version), -1);
|
||||
isFirstRun = lastUsedPrefVersion == -1;
|
||||
|
||||
// Initialize settings first because other initializations can use its values
|
||||
NewPipeSettings.initSettings(this);
|
||||
|
||||
NewPipe.init(getDownloader(),
|
||||
Localization.getPreferredLocalization(this),
|
||||
Localization.getPreferredContentCountry(this));
|
||||
Localization.initPrettyTime(Localization.resolvePrettyTime());
|
||||
|
||||
BridgeStateSaverInitializer.init(this);
|
||||
StateSaver.init(this);
|
||||
initNotificationChannels();
|
||||
|
||||
ServiceHelper.initServices(this);
|
||||
|
||||
// Initialize image loader
|
||||
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
|
||||
PicassoHelper.init(this);
|
||||
ImageStrategy.setPreferredImageQuality(PreferredImageQuality.fromPreferenceKey(this,
|
||||
prefs.getString(getString(R.string.image_quality_key),
|
||||
getString(R.string.image_quality_default))));
|
||||
PicassoHelper.setIndicatorsEnabled(MainActivity.DEBUG
|
||||
&& prefs.getBoolean(getString(R.string.show_image_indicators_key), false));
|
||||
|
||||
configureRxJavaErrorHandler();
|
||||
|
||||
YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl.INSTANCE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTerminate() {
|
||||
super.onTerminate();
|
||||
PicassoHelper.terminate();
|
||||
}
|
||||
|
||||
protected Downloader getDownloader() {
|
||||
final DownloaderImpl downloader = DownloaderImpl.init(null);
|
||||
setCookiesToDownloader(downloader);
|
||||
return downloader;
|
||||
}
|
||||
|
||||
protected void setCookiesToDownloader(final DownloaderImpl downloader) {
|
||||
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(
|
||||
getApplicationContext());
|
||||
final String key = getApplicationContext().getString(R.string.recaptcha_cookies_key);
|
||||
downloader.setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, prefs.getString(key, null));
|
||||
downloader.updateYoutubeRestrictedModeCookies(getApplicationContext());
|
||||
}
|
||||
|
||||
private void configureRxJavaErrorHandler() {
|
||||
// https://github.com/ReactiveX/RxJava/wiki/What's-different-in-2.0#error-handling
|
||||
RxJavaPlugins.setErrorHandler(new Consumer<Throwable>() {
|
||||
@Override
|
||||
public void accept(@NonNull final Throwable throwable) {
|
||||
Log.e(TAG, "RxJavaPlugins.ErrorHandler called with -> : "
|
||||
+ "throwable = [" + throwable.getClass().getName() + "]");
|
||||
|
||||
final Throwable actualThrowable;
|
||||
if (throwable instanceof UndeliverableException) {
|
||||
// As UndeliverableException is a wrapper,
|
||||
// get the cause of it to get the "real" exception
|
||||
actualThrowable = Objects.requireNonNull(throwable.getCause());
|
||||
} else {
|
||||
actualThrowable = throwable;
|
||||
}
|
||||
|
||||
final List<Throwable> errors;
|
||||
if (actualThrowable instanceof CompositeException) {
|
||||
errors = ((CompositeException) actualThrowable).getExceptions();
|
||||
} else {
|
||||
errors = List.of(actualThrowable);
|
||||
}
|
||||
|
||||
for (final Throwable error : errors) {
|
||||
if (isThrowableIgnored(error)) {
|
||||
return;
|
||||
}
|
||||
if (isThrowableCritical(error)) {
|
||||
reportException(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Out-of-lifecycle exceptions should only be reported if a debug user wishes so,
|
||||
// When exception is not reported, log it
|
||||
if (isDisposedRxExceptionsReported()) {
|
||||
reportException(actualThrowable);
|
||||
} else {
|
||||
Log.e(TAG, "RxJavaPlugin: Undeliverable Exception received: ", actualThrowable);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isThrowableIgnored(@NonNull final Throwable throwable) {
|
||||
// Don't crash the application over a simple network problem
|
||||
return ExceptionUtils.hasAssignableCause(throwable,
|
||||
// network api cancellation
|
||||
IOException.class, SocketException.class,
|
||||
// blocking code disposed
|
||||
InterruptedException.class, InterruptedIOException.class);
|
||||
}
|
||||
|
||||
private boolean isThrowableCritical(@NonNull final Throwable throwable) {
|
||||
// Though these exceptions cannot be ignored
|
||||
return ExceptionUtils.hasAssignableCause(throwable,
|
||||
NullPointerException.class, IllegalArgumentException.class, // bug in app
|
||||
OnErrorNotImplementedException.class, MissingBackpressureException.class,
|
||||
IllegalStateException.class); // bug in operator
|
||||
}
|
||||
|
||||
private void reportException(@NonNull final Throwable throwable) {
|
||||
// Throw uncaught exception that will trigger the report system
|
||||
Thread.currentThread().getUncaughtExceptionHandler()
|
||||
.uncaughtException(Thread.currentThread(), throwable);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called in {@link #attachBaseContext(Context)} after calling the {@code super} method.
|
||||
* Should be overridden if MultiDex is enabled, since it has to be initialized before ACRA.
|
||||
*/
|
||||
protected void initACRA() {
|
||||
if (ACRA.isACRASenderServiceProcess()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final CoreConfigurationBuilder acraConfig = new CoreConfigurationBuilder()
|
||||
.withBuildConfigClass(BuildConfig.class);
|
||||
ACRA.init(this, acraConfig);
|
||||
}
|
||||
|
||||
private void initNotificationChannels() {
|
||||
// Keep the importance below DEFAULT to avoid making noise on every notification update for
|
||||
// the main and update channels
|
||||
final List<NotificationChannelCompat> notificationChannelCompats = List.of(
|
||||
new NotificationChannelCompat.Builder(getString(R.string.notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.notification_channel_name))
|
||||
.setDescription(getString(R.string.notification_channel_description))
|
||||
.build(),
|
||||
new NotificationChannelCompat
|
||||
.Builder(getString(R.string.app_update_notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.app_update_notification_channel_name))
|
||||
.setDescription(
|
||||
getString(R.string.app_update_notification_channel_description))
|
||||
.build(),
|
||||
new NotificationChannelCompat.Builder(getString(R.string.hash_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||
.setName(getString(R.string.hash_channel_name))
|
||||
.setDescription(getString(R.string.hash_channel_description))
|
||||
.build(),
|
||||
new NotificationChannelCompat.Builder(getString(R.string.error_report_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW)
|
||||
.setName(getString(R.string.error_report_channel_name))
|
||||
.setDescription(getString(R.string.error_report_channel_description))
|
||||
.build(),
|
||||
new NotificationChannelCompat
|
||||
.Builder(getString(R.string.streams_notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
||||
.setName(getString(R.string.streams_notification_channel_name))
|
||||
.setDescription(
|
||||
getString(R.string.streams_notification_channel_description))
|
||||
.build()
|
||||
);
|
||||
|
||||
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
|
||||
notificationManager.createNotificationChannelsCompat(notificationChannelCompats);
|
||||
}
|
||||
|
||||
protected boolean isDisposedRxExceptionsReported() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isFirstRun() {
|
||||
return isFirstRun;
|
||||
}
|
||||
}
|
||||
296
app/src/main/java/org/schabi/newpipe/App.kt
Normal file
296
app/src/main/java/org/schabi/newpipe/App.kt
Normal file
@@ -0,0 +1,296 @@
|
||||
package org.schabi.newpipe
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationChannelCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.preference.PreferenceManager
|
||||
import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.allowRgb565
|
||||
import coil3.request.crossfade
|
||||
import coil3.util.DebugLogger
|
||||
import com.jakewharton.processphoenix.ProcessPhoenix
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import io.reactivex.rxjava3.exceptions.CompositeException
|
||||
import io.reactivex.rxjava3.exceptions.MissingBackpressureException
|
||||
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException
|
||||
import io.reactivex.rxjava3.exceptions.UndeliverableException
|
||||
import io.reactivex.rxjava3.functions.Consumer
|
||||
import io.reactivex.rxjava3.plugins.RxJavaPlugins
|
||||
import org.acra.ACRA.init
|
||||
import org.acra.ACRA.isACRASenderServiceProcess
|
||||
import org.acra.config.CoreConfigurationBuilder
|
||||
import org.schabi.newpipe.error.ReCaptchaActivity
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
import org.schabi.newpipe.extractor.downloader.Downloader
|
||||
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor
|
||||
import org.schabi.newpipe.ktx.hasAssignableCause
|
||||
import org.schabi.newpipe.settings.NewPipeSettings
|
||||
import org.schabi.newpipe.util.BridgeStateSaverInitializer
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.ServiceHelper
|
||||
import org.schabi.newpipe.util.StateSaver
|
||||
import org.schabi.newpipe.util.image.ImageStrategy
|
||||
import org.schabi.newpipe.util.image.PreferredImageQuality
|
||||
import org.schabi.newpipe.util.potoken.PoTokenProviderImpl
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.SocketException
|
||||
|
||||
/*
|
||||
* Copyright (C) Hans-Christoph Steiner 2016 <hans@eds.org>
|
||||
* App.kt is part of NewPipe.
|
||||
*
|
||||
* NewPipe 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.
|
||||
*
|
||||
* NewPipe 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 NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@HiltAndroidApp
|
||||
open class App :
|
||||
Application(),
|
||||
SingletonImageLoader.Factory {
|
||||
var isFirstRun = false
|
||||
private set
|
||||
var notificationsRequested = false
|
||||
private set
|
||||
|
||||
fun setNotificationsRequested() {
|
||||
notificationsRequested = true
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
initACRA()
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
instance = this
|
||||
|
||||
if (ProcessPhoenix.isPhoenixProcess(this)) {
|
||||
Log.i(TAG, "This is a phoenix process! Aborting initialization of App[onCreate]")
|
||||
return
|
||||
}
|
||||
|
||||
// check if the last used preference version is set
|
||||
// to determine whether this is the first app run
|
||||
val lastUsedPrefVersion =
|
||||
PreferenceManager
|
||||
.getDefaultSharedPreferences(this)
|
||||
.getInt(getString(R.string.last_used_preferences_version), -1)
|
||||
isFirstRun = lastUsedPrefVersion == -1
|
||||
|
||||
// Initialize settings first because other initializations can use its values
|
||||
NewPipeSettings.initSettings(this)
|
||||
|
||||
NewPipe.init(
|
||||
getDownloader(),
|
||||
Localization.getPreferredLocalization(this),
|
||||
Localization.getPreferredContentCountry(this),
|
||||
)
|
||||
Localization.initPrettyTime(Localization.resolvePrettyTime())
|
||||
|
||||
BridgeStateSaverInitializer.init(this)
|
||||
StateSaver.init(this)
|
||||
initNotificationChannels()
|
||||
|
||||
ServiceHelper.initServices(this)
|
||||
|
||||
// Initialize image loader
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
ImageStrategy.setPreferredImageQuality(
|
||||
PreferredImageQuality.fromPreferenceKey(
|
||||
this,
|
||||
prefs.getString(
|
||||
getString(R.string.image_quality_key),
|
||||
getString(R.string.image_quality_default),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
configureRxJavaErrorHandler()
|
||||
|
||||
YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl)
|
||||
}
|
||||
|
||||
override fun newImageLoader(context: Context): ImageLoader =
|
||||
ImageLoader
|
||||
.Builder(this)
|
||||
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
|
||||
.allowRgb565(getSystemService<ActivityManager>()!!.isLowRamDevice)
|
||||
.crossfade(true)
|
||||
.components {
|
||||
add(OkHttpNetworkFetcherFactory(callFactory = DownloaderImpl.getInstance().client))
|
||||
}.build()
|
||||
|
||||
protected open fun getDownloader(): Downloader {
|
||||
val downloader = DownloaderImpl.init(null)
|
||||
setCookiesToDownloader(downloader)
|
||||
return downloader
|
||||
}
|
||||
|
||||
protected fun setCookiesToDownloader(downloader: DownloaderImpl) {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
val key = getString(R.string.recaptcha_cookies_key)
|
||||
downloader.setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, prefs.getString(key, null))
|
||||
downloader.updateYoutubeRestrictedModeCookies(this)
|
||||
}
|
||||
|
||||
private fun configureRxJavaErrorHandler() {
|
||||
// https://github.com/ReactiveX/RxJava/wiki/What's-different-in-2.0#error-handling
|
||||
RxJavaPlugins.setErrorHandler(
|
||||
object : Consumer<Throwable> {
|
||||
override fun accept(throwable: Throwable) {
|
||||
Log.e(TAG, "RxJavaPlugins.ErrorHandler called with -> : throwable = [${throwable.javaClass.getName()}]")
|
||||
|
||||
// As UndeliverableException is a wrapper,
|
||||
// get the cause of it to get the "real" exception
|
||||
val actualThrowable = (throwable as? UndeliverableException)?.cause ?: throwable
|
||||
|
||||
val errors = (actualThrowable as? CompositeException)?.exceptions ?: listOf(actualThrowable)
|
||||
|
||||
for (error in errors) {
|
||||
if (isThrowableIgnored(error)) {
|
||||
return
|
||||
}
|
||||
if (isThrowableCritical(error)) {
|
||||
reportException(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Out-of-lifecycle exceptions should only be reported if a debug user wishes so,
|
||||
// When exception is not reported, log it
|
||||
if (isDisposedRxExceptionsReported()) {
|
||||
reportException(actualThrowable)
|
||||
} else {
|
||||
Log.e(TAG, "RxJavaPlugin: Undeliverable Exception received: ", actualThrowable)
|
||||
}
|
||||
}
|
||||
|
||||
fun isThrowableIgnored(throwable: Throwable): Boolean {
|
||||
// Don't crash the application over a simple network problem
|
||||
return throwable // network api cancellation
|
||||
.hasAssignableCause(
|
||||
IOException::class.java,
|
||||
SocketException::class.java, // blocking code disposed
|
||||
InterruptedException::class.java,
|
||||
InterruptedIOException::class.java,
|
||||
)
|
||||
}
|
||||
|
||||
fun isThrowableCritical(throwable: Throwable): Boolean {
|
||||
// Though these exceptions cannot be ignored
|
||||
return throwable
|
||||
.hasAssignableCause(
|
||||
// bug in app
|
||||
NullPointerException::class.java,
|
||||
IllegalArgumentException::class.java,
|
||||
OnErrorNotImplementedException::class.java,
|
||||
MissingBackpressureException::class.java,
|
||||
// bug in operator
|
||||
IllegalStateException::class.java,
|
||||
)
|
||||
}
|
||||
|
||||
fun reportException(throwable: Throwable) {
|
||||
// Throw uncaught exception that will trigger the report system
|
||||
Thread
|
||||
.currentThread()
|
||||
.uncaughtExceptionHandler
|
||||
.uncaughtException(Thread.currentThread(), throwable)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called in [.attachBaseContext] after calling the `super` method.
|
||||
* Should be overridden if MultiDex is enabled, since it has to be initialized before ACRA.
|
||||
*/
|
||||
protected fun initACRA() {
|
||||
if (isACRASenderServiceProcess()) {
|
||||
return
|
||||
}
|
||||
|
||||
val acraConfig =
|
||||
CoreConfigurationBuilder()
|
||||
.withBuildConfigClass(BuildConfig::class.java)
|
||||
init(this, acraConfig)
|
||||
}
|
||||
|
||||
private fun initNotificationChannels() {
|
||||
// Keep the importance below DEFAULT to avoid making noise on every notification update for
|
||||
// the main and update channels
|
||||
val mainChannel =
|
||||
NotificationChannelCompat
|
||||
.Builder(
|
||||
getString(R.string.notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW,
|
||||
).setName(getString(R.string.notification_channel_name))
|
||||
.setDescription(getString(R.string.notification_channel_description))
|
||||
.build()
|
||||
val appUpdateChannel =
|
||||
NotificationChannelCompat
|
||||
.Builder(
|
||||
getString(R.string.app_update_notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW,
|
||||
).setName(getString(R.string.app_update_notification_channel_name))
|
||||
.setDescription(getString(R.string.app_update_notification_channel_description))
|
||||
.build()
|
||||
val hashChannel =
|
||||
NotificationChannelCompat
|
||||
.Builder(
|
||||
getString(R.string.hash_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_HIGH,
|
||||
).setName(getString(R.string.hash_channel_name))
|
||||
.setDescription(getString(R.string.hash_channel_description))
|
||||
.build()
|
||||
val errorReportChannel =
|
||||
NotificationChannelCompat
|
||||
.Builder(
|
||||
getString(R.string.error_report_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_LOW,
|
||||
).setName(getString(R.string.error_report_channel_name))
|
||||
.setDescription(getString(R.string.error_report_channel_description))
|
||||
.build()
|
||||
val newStreamChannel =
|
||||
NotificationChannelCompat
|
||||
.Builder(
|
||||
getString(R.string.streams_notification_channel_id),
|
||||
NotificationManagerCompat.IMPORTANCE_DEFAULT,
|
||||
).setName(getString(R.string.streams_notification_channel_name))
|
||||
.setDescription(getString(R.string.streams_notification_channel_description))
|
||||
.build()
|
||||
|
||||
val channels = listOf(mainChannel, appUpdateChannel, hashChannel, errorReportChannel, newStreamChannel)
|
||||
|
||||
NotificationManagerCompat.from(this).createNotificationChannelsCompat(channels)
|
||||
}
|
||||
|
||||
protected open fun isDisposedRxExceptionsReported(): Boolean = false
|
||||
|
||||
companion object {
|
||||
const val PACKAGE_NAME: String = BuildConfig.APPLICATION_ID
|
||||
private val TAG = App::class.java.toString()
|
||||
|
||||
@JvmStatic
|
||||
lateinit var instance: App
|
||||
private set
|
||||
}
|
||||
}
|
||||
22
app/src/main/java/org/schabi/newpipe/AppModule.kt
Normal file
22
app/src/main/java/org/schabi/newpipe/AppModule.kt
Normal file
@@ -0,0 +1,22 @@
|
||||
package org.schabi.newpipe
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
class AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providesSharedPreference(@ApplicationContext context: Context): SharedPreferences {
|
||||
return PreferenceManager.getDefaultSharedPreferences(context)
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,11 @@ public final class DownloaderImpl extends Downloader {
|
||||
this.mCookies = new HashMap<>();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public OkHttpClient getClient() {
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* It's recommended to call exactly once in the entire lifetime of the application.
|
||||
*
|
||||
|
||||
@@ -43,7 +43,6 @@ import android.widget.FrameLayout;
|
||||
import android.widget.Spinner;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.ActionBarDrawerToggle;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
@@ -52,7 +51,6 @@ import androidx.core.content.ContextCompat;
|
||||
import androidx.core.view.GravityCompat;
|
||||
import androidx.drawerlayout.widget.DrawerLayout;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentContainerView;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
@@ -66,13 +64,11 @@ import org.schabi.newpipe.databinding.ToolbarLayoutBinding;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance;
|
||||
import org.schabi.newpipe.fragments.BackPressable;
|
||||
import org.schabi.newpipe.fragments.MainFragment;
|
||||
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
|
||||
import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment;
|
||||
import org.schabi.newpipe.fragments.list.search.SearchFragment;
|
||||
import org.schabi.newpipe.local.feed.notifications.NotificationWorker;
|
||||
import org.schabi.newpipe.player.Player;
|
||||
@@ -191,7 +187,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
NotificationWorker.initialize(this);
|
||||
}
|
||||
if (!UpdateSettingsFragment.wasUserAskedForConsent(this)
|
||||
&& !App.getApp().isFirstRun()
|
||||
&& !App.getInstance().isFirstRun()
|
||||
&& ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
|
||||
UpdateSettingsFragment.askForConsentToUpdateChecks(this);
|
||||
}
|
||||
@@ -203,7 +199,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
protected void onPostCreate(final Bundle savedInstanceState) {
|
||||
super.onPostCreate(savedInstanceState);
|
||||
|
||||
final App app = App.getApp();
|
||||
final App app = App.getInstance();
|
||||
|
||||
if (sharedPreferences.getBoolean(app.getString(R.string.update_app_key), false)
|
||||
&& sharedPreferences
|
||||
@@ -603,39 +599,27 @@ public class MainActivity extends AppCompatActivity {
|
||||
// In case bottomSheet is not visible on the screen or collapsed we can assume that the user
|
||||
// interacts with a fragment inside fragment_holder so all back presses should be
|
||||
// handled by it
|
||||
if (bottomSheetHiddenOrCollapsed()) {
|
||||
final FragmentManager fm = getSupportFragmentManager();
|
||||
final Fragment fragment = fm.findFragmentById(R.id.fragment_holder);
|
||||
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
|
||||
// delegate the back press to it
|
||||
if (fragment instanceof BackPressable) {
|
||||
if (((BackPressable) fragment).onBackPressed()) {
|
||||
return;
|
||||
}
|
||||
} else if (fragment instanceof CommentRepliesFragment) {
|
||||
// expand DetailsFragment if CommentRepliesFragment was opened
|
||||
// to show the top level comments again
|
||||
// Expand DetailsFragment if CommentRepliesFragment was opened
|
||||
// and no other CommentRepliesFragments are on top of the back stack
|
||||
// to show the top level comments again.
|
||||
openDetailFragmentFromCommentReplies(fm, false);
|
||||
}
|
||||
final var fragmentManager = getSupportFragmentManager();
|
||||
|
||||
} else {
|
||||
final Fragment fragmentPlayer = getSupportFragmentManager()
|
||||
.findFragmentById(R.id.fragment_player_holder);
|
||||
if (bottomSheetHiddenOrCollapsed()) {
|
||||
final var fragment = fragmentManager.findFragmentById(R.id.fragment_holder);
|
||||
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
|
||||
// delegate the back press to it
|
||||
if (fragmentPlayer instanceof BackPressable) {
|
||||
if (!((BackPressable) fragmentPlayer).onBackPressed()) {
|
||||
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder)
|
||||
.setState(BottomSheetBehavior.STATE_COLLAPSED);
|
||||
}
|
||||
if (fragment instanceof BackPressable backPressable && backPressable.onBackPressed()) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
final var player = fragmentManager.findFragmentById(R.id.fragment_player_holder);
|
||||
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
|
||||
// delegate the back press to it
|
||||
if (player instanceof BackPressable backPressable && !backPressable.onBackPressed()) {
|
||||
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder)
|
||||
.setState(BottomSheetBehavior.STATE_COLLAPSED);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (getSupportFragmentManager().getBackStackEntryCount() == 1) {
|
||||
if (fragmentManager.getBackStackEntryCount() == 1) {
|
||||
finish();
|
||||
} else {
|
||||
super.onBackPressed();
|
||||
@@ -694,15 +678,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
* </pre>
|
||||
*/
|
||||
private void onHomeButtonPressed() {
|
||||
final FragmentManager fm = getSupportFragmentManager();
|
||||
final Fragment fragment = fm.findFragmentById(R.id.fragment_holder);
|
||||
final var fm = getSupportFragmentManager();
|
||||
|
||||
if (fragment instanceof CommentRepliesFragment) {
|
||||
// Expand DetailsFragment if CommentRepliesFragment was opened
|
||||
// and no other CommentRepliesFragments are on top of the back stack
|
||||
// to show the top level comments again.
|
||||
openDetailFragmentFromCommentReplies(fm, true);
|
||||
} else if (!NavigationHelper.tryGotoSearchFragment(fm)) {
|
||||
if (!NavigationHelper.tryGotoSearchFragment(fm)) {
|
||||
// If search fragment wasn't found in the backstack go to the main fragment
|
||||
NavigationHelper.gotoMainFragment(fm);
|
||||
}
|
||||
@@ -876,7 +854,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
return;
|
||||
}
|
||||
|
||||
if (PlayerHolder.getInstance().isPlayerOpen()) {
|
||||
if (PlayerHolder.INSTANCE.isPlayerOpen()) {
|
||||
// if the player is already open, no need for a broadcast receiver
|
||||
openMiniPlayerIfMissing();
|
||||
} else {
|
||||
@@ -886,7 +864,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
public void onReceive(final Context context, final Intent intent) {
|
||||
if (Objects.equals(intent.getAction(),
|
||||
VideoDetailFragment.ACTION_PLAYER_STARTED)
|
||||
&& PlayerHolder.getInstance().isPlayerOpen()) {
|
||||
&& PlayerHolder.INSTANCE.isPlayerOpen()) {
|
||||
openMiniPlayerIfMissing();
|
||||
// At this point the player is added 100%, we can unregister. Other actions
|
||||
// are useless since the fragment will not be removed after that.
|
||||
@@ -902,72 +880,10 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
// If the PlayerHolder is not bound yet, but the service is running, try to bind to it.
|
||||
// Once the connection is established, the ACTION_PLAYER_STARTED will be sent.
|
||||
PlayerHolder.getInstance().tryBindIfNeeded(this);
|
||||
PlayerHolder.INSTANCE.tryBindIfNeeded(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void openDetailFragmentFromCommentReplies(
|
||||
@NonNull final FragmentManager fm,
|
||||
final boolean popBackStack
|
||||
) {
|
||||
// obtain the name of the fragment under the replies fragment that's going to be popped
|
||||
@Nullable final String fragmentUnderEntryName;
|
||||
if (fm.getBackStackEntryCount() < 2) {
|
||||
fragmentUnderEntryName = null;
|
||||
} else {
|
||||
fragmentUnderEntryName = fm.getBackStackEntryAt(fm.getBackStackEntryCount() - 2)
|
||||
.getName();
|
||||
}
|
||||
|
||||
// the root comment is the comment for which the user opened the replies page
|
||||
@Nullable final CommentRepliesFragment repliesFragment =
|
||||
(CommentRepliesFragment) fm.findFragmentByTag(CommentRepliesFragment.TAG);
|
||||
@Nullable final CommentsInfoItem rootComment =
|
||||
repliesFragment == null ? null : repliesFragment.getCommentsInfoItem();
|
||||
|
||||
// sometimes this function pops the backstack, other times it's handled by the system
|
||||
if (popBackStack) {
|
||||
fm.popBackStackImmediate();
|
||||
}
|
||||
|
||||
// only expand the bottom sheet back if there are no more nested comment replies fragments
|
||||
// stacked under the one that is currently being popped
|
||||
if (CommentRepliesFragment.TAG.equals(fragmentUnderEntryName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final BottomSheetBehavior<FragmentContainerView> behavior = BottomSheetBehavior
|
||||
.from(mainBinding.fragmentPlayerHolder);
|
||||
// do not return to the comment if the details fragment was closed
|
||||
if (behavior.getState() == BottomSheetBehavior.STATE_HIDDEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
// scroll to the root comment once the bottom sheet expansion animation is finished
|
||||
behavior.addBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() {
|
||||
@Override
|
||||
public void onStateChanged(@NonNull final View bottomSheet,
|
||||
final int newState) {
|
||||
if (newState == BottomSheetBehavior.STATE_EXPANDED) {
|
||||
final Fragment detailFragment = fm.findFragmentById(
|
||||
R.id.fragment_player_holder);
|
||||
if (detailFragment instanceof VideoDetailFragment && rootComment != null) {
|
||||
// should always be the case
|
||||
((VideoDetailFragment) detailFragment).scrollToComment(rootComment);
|
||||
}
|
||||
behavior.removeBottomSheetCallback(this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSlide(@NonNull final View bottomSheet, final float slideOffset) {
|
||||
// not needed, listener is removed once the sheet is expanded
|
||||
}
|
||||
});
|
||||
|
||||
behavior.setState(BottomSheetBehavior.STATE_EXPANDED);
|
||||
}
|
||||
|
||||
private boolean bottomSheetHiddenOrCollapsed() {
|
||||
final BottomSheetBehavior<FrameLayout> bottomSheetBehavior =
|
||||
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder);
|
||||
|
||||
@@ -670,7 +670,7 @@ public class RouterActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
// ...the player is not running or in normal Video-mode/type
|
||||
final PlayerType playerType = PlayerHolder.getInstance().getType();
|
||||
final PlayerType playerType = PlayerHolder.INSTANCE.getType();
|
||||
return playerType == null || playerType == PlayerType.MAIN;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,201 +1,29 @@
|
||||
package org.schabi.newpipe.about
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.databinding.ActivityAboutBinding
|
||||
import org.schabi.newpipe.databinding.FragmentAboutBinding
|
||||
import org.schabi.newpipe.util.ThemeHelper
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
import org.schabi.newpipe.ui.components.common.ScaffoldWithToolbar
|
||||
import org.schabi.newpipe.ui.screens.AboutScreen
|
||||
import org.schabi.newpipe.ui.theme.AppTheme
|
||||
|
||||
class AboutActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
ThemeHelper.setTheme(this)
|
||||
title = getString(R.string.title_activity_about)
|
||||
|
||||
val aboutBinding = ActivityAboutBinding.inflate(layoutInflater)
|
||||
setContentView(aboutBinding.root)
|
||||
setSupportActionBar(aboutBinding.aboutToolbar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
// Create the adapter that will return a fragment for each of the three
|
||||
// primary sections of the activity.
|
||||
val mAboutStateAdapter = AboutStateAdapter(this)
|
||||
// Set up the ViewPager with the sections adapter.
|
||||
aboutBinding.aboutViewPager2.adapter = mAboutStateAdapter
|
||||
TabLayoutMediator(
|
||||
aboutBinding.aboutTabLayout,
|
||||
aboutBinding.aboutViewPager2
|
||||
) { tab, position ->
|
||||
tab.setText(mAboutStateAdapter.getPageTitle(position))
|
||||
}.attach()
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == android.R.id.home) {
|
||||
finish()
|
||||
return true
|
||||
}
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* A placeholder fragment containing a simple view.
|
||||
*/
|
||||
class AboutFragment : Fragment() {
|
||||
private fun Button.openLink(@StringRes url: Int) {
|
||||
setOnClickListener {
|
||||
ShareUtils.openUrlInApp(context, requireContext().getString(url))
|
||||
setContent {
|
||||
AppTheme {
|
||||
ScaffoldWithToolbar(
|
||||
title = stringResource(R.string.title_activity_about),
|
||||
onBackClick = { onBackPressedDispatcher.onBackPressed() }
|
||||
) { padding ->
|
||||
AboutScreen(padding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
FragmentAboutBinding.inflate(inflater, container, false).apply {
|
||||
aboutAppVersion.text = BuildConfig.VERSION_NAME
|
||||
aboutGithubLink.openLink(R.string.github_url)
|
||||
aboutDonationLink.openLink(R.string.donation_url)
|
||||
aboutWebsiteLink.openLink(R.string.website_url)
|
||||
aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url)
|
||||
faqLink.openLink(R.string.faq_url)
|
||||
return root
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [FragmentStateAdapter] that returns a fragment corresponding to
|
||||
* one of the sections/tabs/pages.
|
||||
*/
|
||||
private class AboutStateAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
|
||||
private val posAbout = 0
|
||||
private val posLicense = 1
|
||||
private val totalCount = 2
|
||||
|
||||
override fun createFragment(position: Int): Fragment {
|
||||
return when (position) {
|
||||
posAbout -> AboutFragment()
|
||||
posLicense -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS)
|
||||
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
// Show 2 total pages.
|
||||
return totalCount
|
||||
}
|
||||
|
||||
fun getPageTitle(position: Int): Int {
|
||||
return when (position) {
|
||||
posAbout -> R.string.tab_about
|
||||
posLicense -> R.string.tab_licenses
|
||||
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* List of all software components.
|
||||
*/
|
||||
private val SOFTWARE_COMPONENTS = arrayListOf(
|
||||
SoftwareComponent(
|
||||
"ACRA", "2013", "Kevin Gaudin",
|
||||
"https://github.com/ACRA/acra", StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"AndroidX", "2005 - 2011", "The Android Open Source Project",
|
||||
"https://developer.android.com/jetpack", StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"ExoPlayer", "2014 - 2020", "Google, Inc.",
|
||||
"https://github.com/google/ExoPlayer", StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"GigaGet", "2014 - 2015", "Peter Cai",
|
||||
"https://github.com/PaperAirplane-Dev-Team/GigaGet", StandardLicenses.GPL3
|
||||
),
|
||||
SoftwareComponent(
|
||||
"Groupie", "2016", "Lisa Wray",
|
||||
"https://github.com/lisawray/groupie", StandardLicenses.MIT
|
||||
),
|
||||
SoftwareComponent(
|
||||
"Android-State", "2018", "Evernote",
|
||||
"https://github.com/Evernote/android-state", StandardLicenses.EPL1
|
||||
),
|
||||
SoftwareComponent(
|
||||
"Bridge", "2021", "Livefront",
|
||||
"https://github.com/livefront/bridge", StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"Jsoup", "2009 - 2020", "Jonathan Hedley",
|
||||
"https://github.com/jhy/jsoup", StandardLicenses.MIT
|
||||
),
|
||||
SoftwareComponent(
|
||||
"Markwon", "2019", "Dimitry Ivanov",
|
||||
"https://github.com/noties/Markwon", StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"Material Components for Android", "2016 - 2020", "Google, Inc.",
|
||||
"https://github.com/material-components/material-components-android",
|
||||
StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"NewPipe Extractor", "2017 - 2020", "Christian Schabesberger",
|
||||
"https://github.com/TeamNewPipe/NewPipeExtractor", StandardLicenses.GPL3
|
||||
),
|
||||
SoftwareComponent(
|
||||
"NoNonsense-FilePicker", "2016", "Jonas Kalderstam",
|
||||
"https://github.com/spacecowboy/NoNonsense-FilePicker", StandardLicenses.MPL2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"OkHttp", "2019", "Square, Inc.",
|
||||
"https://square.github.io/okhttp/", StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"Picasso", "2013", "Square, Inc.",
|
||||
"https://square.github.io/picasso/", StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"PrettyTime", "2012 - 2020", "Lincoln Baxter, III",
|
||||
"https://github.com/ocpsoft/prettytime", StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"ProcessPhoenix", "2015", "Jake Wharton",
|
||||
"https://github.com/JakeWharton/ProcessPhoenix", StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"RxAndroid", "2015", "The RxAndroid authors",
|
||||
"https://github.com/ReactiveX/RxAndroid", StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"RxBinding", "2015", "Jake Wharton",
|
||||
"https://github.com/JakeWharton/RxBinding", StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"RxJava", "2016 - 2020", "RxJava Contributors",
|
||||
"https://github.com/ReactiveX/RxJava", StandardLicenses.APACHE2
|
||||
),
|
||||
SoftwareComponent(
|
||||
"SearchPreference", "2018", "ByteHamster",
|
||||
"https://github.com/ByteHamster/SearchPreference", StandardLicenses.MIT
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package org.schabi.newpipe.about
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.io.Serializable
|
||||
|
||||
/**
|
||||
* Class for storing information about a software license.
|
||||
*/
|
||||
@Parcelize
|
||||
class License(val name: String, val abbreviation: String, val filename: String) : Parcelable, Serializable
|
||||
@@ -1,138 +0,0 @@
|
||||
package org.schabi.newpipe.about
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.databinding.FragmentLicensesBinding
|
||||
import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding
|
||||
import org.schabi.newpipe.ktx.parcelableArrayList
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
|
||||
/**
|
||||
* Fragment containing the software licenses.
|
||||
*/
|
||||
class LicenseFragment : Fragment() {
|
||||
private lateinit var softwareComponents: List<SoftwareComponent>
|
||||
private var activeSoftwareComponent: SoftwareComponent? = null
|
||||
private val compositeDisposable = CompositeDisposable()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
softwareComponents = arguments?.parcelableArrayList<SoftwareComponent>(ARG_COMPONENTS)!!
|
||||
.sortedBy { it.name } // Sort components by name
|
||||
activeSoftwareComponent = savedInstanceState?.getSerializable(SOFTWARE_COMPONENT_KEY) as? SoftwareComponent
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
compositeDisposable.dispose()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val binding = FragmentLicensesBinding.inflate(inflater, container, false)
|
||||
binding.licensesAppReadLicense.setOnClickListener {
|
||||
compositeDisposable.add(
|
||||
showLicense(NEWPIPE_SOFTWARE_COMPONENT)
|
||||
)
|
||||
}
|
||||
for (component in softwareComponents) {
|
||||
val componentBinding = ItemSoftwareComponentBinding
|
||||
.inflate(inflater, container, false)
|
||||
componentBinding.name.text = component.name
|
||||
componentBinding.copyright.text = getString(
|
||||
R.string.copyright,
|
||||
component.years,
|
||||
component.copyrightOwner,
|
||||
component.license.abbreviation
|
||||
)
|
||||
val root: View = componentBinding.root
|
||||
root.tag = component
|
||||
root.setOnClickListener {
|
||||
compositeDisposable.add(
|
||||
showLicense(component)
|
||||
)
|
||||
}
|
||||
binding.licensesSoftwareComponents.addView(root)
|
||||
registerForContextMenu(root)
|
||||
}
|
||||
activeSoftwareComponent?.let { compositeDisposable.add(showLicense(it)) }
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(savedInstanceState: Bundle) {
|
||||
super.onSaveInstanceState(savedInstanceState)
|
||||
activeSoftwareComponent?.let { savedInstanceState.putSerializable(SOFTWARE_COMPONENT_KEY, it) }
|
||||
}
|
||||
|
||||
private fun showLicense(
|
||||
softwareComponent: SoftwareComponent
|
||||
): Disposable {
|
||||
return if (context == null) {
|
||||
Disposable.empty()
|
||||
} else {
|
||||
val context = requireContext()
|
||||
activeSoftwareComponent = softwareComponent
|
||||
Observable.fromCallable { getFormattedLicense(context, softwareComponent.license) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { formattedLicense ->
|
||||
val webViewData = Base64.encodeToString(
|
||||
formattedLicense.toByteArray(), Base64.NO_PADDING
|
||||
)
|
||||
val webView = WebView(context)
|
||||
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
|
||||
|
||||
val builder = AlertDialog.Builder(requireContext())
|
||||
.setTitle(softwareComponent.name)
|
||||
.setView(webView)
|
||||
.setOnCancelListener { activeSoftwareComponent = null }
|
||||
.setOnDismissListener { activeSoftwareComponent = null }
|
||||
.setPositiveButton(R.string.done) { dialog, _ -> dialog.dismiss() }
|
||||
|
||||
if (softwareComponent != NEWPIPE_SOFTWARE_COMPONENT) {
|
||||
builder.setNeutralButton(R.string.open_website_license) { _, _ ->
|
||||
ShareUtils.openUrlInApp(requireContext(), softwareComponent.link)
|
||||
}
|
||||
}
|
||||
|
||||
builder.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_COMPONENTS = "components"
|
||||
private const val SOFTWARE_COMPONENT_KEY = "ACTIVE_SOFTWARE_COMPONENT"
|
||||
private val NEWPIPE_SOFTWARE_COMPONENT = SoftwareComponent(
|
||||
"NewPipe",
|
||||
"2014-2023",
|
||||
"Team NewPipe",
|
||||
"https://newpipe.net/",
|
||||
StandardLicenses.GPL3,
|
||||
BuildConfig.VERSION_NAME
|
||||
)
|
||||
|
||||
fun newInstance(softwareComponents: ArrayList<SoftwareComponent>): LicenseFragment {
|
||||
val fragment = LicenseFragment()
|
||||
fragment.arguments = bundleOf(ARG_COMPONENTS to softwareComponents)
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package org.schabi.newpipe.about
|
||||
|
||||
import android.content.Context
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.util.ThemeHelper
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* @param context the context to use
|
||||
* @param license the license
|
||||
* @return String which contains a HTML formatted license page
|
||||
* styled according to the context's theme
|
||||
*/
|
||||
fun getFormattedLicense(context: Context, license: License): String {
|
||||
try {
|
||||
return context.assets.open(license.filename).bufferedReader().use { it.readText() }
|
||||
// split the HTML file and insert the stylesheet into the HEAD of the file
|
||||
.replace("</head>", "<style>${getLicenseStylesheet(context)}</style></head>")
|
||||
} catch (e: IOException) {
|
||||
throw IllegalArgumentException("Could not get license file: ${license.filename}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param context the Android context
|
||||
* @return String which is a CSS stylesheet according to the context's theme
|
||||
*/
|
||||
fun getLicenseStylesheet(context: Context): String {
|
||||
val isLightTheme = ThemeHelper.isLightThemeSelected(context)
|
||||
val licenseBackgroundColor = getHexRGBColor(
|
||||
context, if (isLightTheme) R.color.light_license_background_color else R.color.dark_license_background_color
|
||||
)
|
||||
val licenseTextColor = getHexRGBColor(
|
||||
context, if (isLightTheme) R.color.light_license_text_color else R.color.dark_license_text_color
|
||||
)
|
||||
val youtubePrimaryColor = getHexRGBColor(
|
||||
context, if (isLightTheme) R.color.light_youtube_primary_color else R.color.dark_youtube_primary_color
|
||||
)
|
||||
return "body{padding:12px 15px;margin:0;background:#$licenseBackgroundColor;color:#$licenseTextColor}" +
|
||||
"a[href]{color:#$youtubePrimaryColor}pre{white-space:pre-wrap}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast R.color to a hexadecimal color value.
|
||||
*
|
||||
* @param context the context to use
|
||||
* @param color the color number from R.color
|
||||
* @return a six characters long String with hexadecimal RGB values
|
||||
*/
|
||||
fun getHexRGBColor(context: Context, color: Int): String {
|
||||
return context.getString(color).substring(3)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package org.schabi.newpipe.about
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.io.Serializable
|
||||
|
||||
@Parcelize
|
||||
class SoftwareComponent
|
||||
@JvmOverloads
|
||||
constructor(
|
||||
val name: String,
|
||||
val years: String,
|
||||
val copyrightOwner: String,
|
||||
val link: String,
|
||||
val license: License,
|
||||
val version: String? = null
|
||||
) : Parcelable, Serializable
|
||||
@@ -1,21 +0,0 @@
|
||||
package org.schabi.newpipe.about
|
||||
|
||||
/**
|
||||
* Class containing information about standard software licenses.
|
||||
*/
|
||||
object StandardLicenses {
|
||||
@JvmField
|
||||
val GPL3 = License("GNU General Public License, Version 3.0", "GPLv3", "gpl_3.html")
|
||||
|
||||
@JvmField
|
||||
val APACHE2 = License("Apache License, Version 2.0", "ALv2", "apache2.html")
|
||||
|
||||
@JvmField
|
||||
val MPL2 = License("Mozilla Public License, Version 2.0", "MPL 2.0", "mpl2.html")
|
||||
|
||||
@JvmField
|
||||
val MIT = License("MIT License", "MIT", "mit.html")
|
||||
|
||||
@JvmField
|
||||
val EPL1 = License("Eclipse Public License, Version 1.0", "EPL 1.0", "epl1.html")
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2017 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.database.history.dao
|
||||
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
|
||||
interface HistoryDAO<T> : BasicDAO<T> {
|
||||
val latestEntry: T
|
||||
}
|
||||
@@ -9,13 +9,14 @@ package org.schabi.newpipe.database.history.dao
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.history.model.SearchHistoryEntry
|
||||
|
||||
@Dao
|
||||
interface SearchHistoryDAO : HistoryDAO<SearchHistoryEntry> {
|
||||
interface SearchHistoryDAO : BasicDAO<SearchHistoryEntry> {
|
||||
|
||||
@get:Query("SELECT * FROM search_history WHERE id = (SELECT MAX(id) FROM search_history)")
|
||||
override val latestEntry: SearchHistoryEntry
|
||||
val latestEntry: SearchHistoryEntry?
|
||||
|
||||
@Query("DELETE FROM search_history")
|
||||
override fun deleteAll(): Int
|
||||
|
||||
@@ -10,15 +10,13 @@ import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntry
|
||||
import org.schabi.newpipe.database.stream.StreamStatisticsEntry
|
||||
|
||||
@Dao
|
||||
abstract class StreamHistoryDAO : HistoryDAO<StreamHistoryEntity> {
|
||||
|
||||
@get:Query("SELECT * FROM stream_history WHERE access_date = (SELECT MAX(access_date) FROM stream_history)")
|
||||
abstract override val latestEntry: StreamHistoryEntity
|
||||
abstract class StreamHistoryDAO : BasicDAO<StreamHistoryEntity> {
|
||||
|
||||
@Query("SELECT * FROM stream_history")
|
||||
abstract override fun getAll(): Flowable<List<StreamHistoryEntity>>
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
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
|
||||
@@ -27,7 +28,7 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
|
||||
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>>
|
||||
abstract fun getStream(serviceId: Long, url: String): Maybe<StreamEntity>
|
||||
|
||||
@Query("UPDATE streams SET uploader_url = :uploaderUrl WHERE url = :url AND service_id = :serviceId")
|
||||
abstract fun setUploaderUrl(serviceId: Long, url: String, uploaderUrl: String): Completable
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
||||
|
||||
@@ -29,12 +30,12 @@ interface StreamStateDAO : BasicDAO<StreamStateEntity> {
|
||||
}
|
||||
|
||||
@Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
|
||||
fun getState(streamId: Long): Flowable<MutableList<StreamStateEntity>>
|
||||
fun getState(streamId: Long): Maybe<StreamStateEntity>
|
||||
|
||||
@Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
|
||||
fun deleteState(streamId: Long): Int
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.Companion.IGNORE)
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
fun silentInsertInternal(streamState: StreamStateEntity)
|
||||
|
||||
@Transaction
|
||||
|
||||
@@ -90,7 +90,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
||||
internal abstract fun silentInsertAllInternal(entities: List<SubscriptionEntity>): List<Long>
|
||||
|
||||
@Transaction
|
||||
open fun upsertAll(entities: List<SubscriptionEntity>): List<SubscriptionEntity> {
|
||||
open fun upsertAll(entities: List<SubscriptionEntity>) {
|
||||
val insertUidList = silentInsertAllInternal(entities)
|
||||
|
||||
insertUidList.forEachIndexed { index: Int, uidFromInsert: Long ->
|
||||
@@ -106,7 +106,5 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
||||
update(entity)
|
||||
}
|
||||
}
|
||||
|
||||
return entities
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.google.auto.service.AutoService;
|
||||
import org.acra.config.CoreConfiguration;
|
||||
import org.acra.sender.ReportSender;
|
||||
import org.acra.sender.ReportSenderFactory;
|
||||
import org.schabi.newpipe.App;
|
||||
|
||||
/*
|
||||
* Created by Christian Schabesberger on 13.09.16.
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.schabi.newpipe.error
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.exoplayer2.ExoPlaybackException
|
||||
import com.google.android.exoplayer2.upstream.HttpDataSource
|
||||
import com.google.android.exoplayer2.upstream.Loader
|
||||
@@ -28,6 +27,7 @@ import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentExcepti
|
||||
import org.schabi.newpipe.ktx.isNetworkRelated
|
||||
import org.schabi.newpipe.player.mediasource.FailedMediaSource
|
||||
import org.schabi.newpipe.player.resolver.PlaybackResolver
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import java.net.UnknownHostException
|
||||
|
||||
/**
|
||||
@@ -147,13 +147,11 @@ class ErrorInfo private constructor(
|
||||
private vararg val formatArgs: String,
|
||||
) : Parcelable {
|
||||
fun getString(context: Context): String {
|
||||
// use Localization.compatGetString() just in case context is not AppCompatActivity
|
||||
return if (formatArgs.isEmpty()) {
|
||||
// use ContextCompat.getString() just in case context is not AppCompatActivity
|
||||
ContextCompat.getString(context, stringRes)
|
||||
Localization.compatGetString(context, stringRes)
|
||||
} else {
|
||||
// ContextCompat.getString() with formatArgs does not exist, so we just
|
||||
// replicate its source code but with formatArgs
|
||||
ContextCompat.getContextForLanguage(context).getString(stringRes, *formatArgs)
|
||||
Localization.compatGetString(context, stringRes, *formatArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,6 +274,9 @@ class ErrorInfo private constructor(
|
||||
// we don't have an exception, so this is a manually built error, which likely
|
||||
// indicates that it's important and is thus reportable
|
||||
null -> true
|
||||
// a recaptcha was detected, and the user needs to solve it, there is no use in
|
||||
// letting users report it
|
||||
is ReCaptchaException -> false
|
||||
// the service explicitly said that content is not available (e.g. age restrictions,
|
||||
// video deleted, etc.), there is no use in letting users report it
|
||||
is ContentNotAvailableException -> false
|
||||
|
||||
@@ -6,9 +6,11 @@ import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.compose.ui.platform.ComposeView;
|
||||
|
||||
import org.schabi.newpipe.BaseFragment;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
|
||||
|
||||
public class EmptyFragment extends BaseFragment {
|
||||
private static final String SHOW_MESSAGE = "SHOW_MESSAGE";
|
||||
@@ -26,8 +28,10 @@ public class EmptyFragment extends BaseFragment {
|
||||
final Bundle savedInstanceState) {
|
||||
final boolean showMessage = getArguments().getBoolean(SHOW_MESSAGE);
|
||||
final View view = inflater.inflate(R.layout.fragment_empty, container, false);
|
||||
view.findViewById(R.id.empty_state_view).setVisibility(
|
||||
showMessage ? View.VISIBLE : View.GONE);
|
||||
|
||||
final ComposeView composeView = view.findViewById(R.id.empty_state_view);
|
||||
EmptyStateUtil.setEmptyStateComposable(composeView);
|
||||
composeView.setVisibility(showMessage ? View.VISIBLE : View.GONE);
|
||||
return view;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ package org.schabi.newpipe.fragments.list.channel;
|
||||
import static org.schabi.newpipe.ktx.TextViewUtils.animateTextColor;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
|
||||
import static org.schabi.newpipe.ui.emptystate.EmptyStateUtil.setEmptyStateComposable;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -10,7 +11,6 @@ import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.util.TypedValue;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
@@ -45,6 +45,7 @@ import org.schabi.newpipe.fragments.detail.TabAdapter;
|
||||
import org.schabi.newpipe.ktx.AnimationType;
|
||||
import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
|
||||
import org.schabi.newpipe.util.ChannelTabHelper;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
@@ -53,13 +54,14 @@ import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.StateSaver;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.util.image.CoilHelper;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import coil3.util.CoilUtils;
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Observable;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
@@ -73,7 +75,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
||||
implements StateSaver.WriteRead {
|
||||
|
||||
private static final int BUTTON_DEBOUNCE_INTERVAL = 100;
|
||||
private static final String PICASSO_CHANNEL_TAG = "PICASSO_CHANNEL_TAG";
|
||||
|
||||
@State
|
||||
protected int serviceId = Constants.NO_SERVICE_ID;
|
||||
@@ -199,6 +200,8 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
||||
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
||||
super.initViews(rootView, savedInstanceState);
|
||||
|
||||
setEmptyStateComposable(binding.emptyStateView, EmptyStateSpec.ContentNotSupported);
|
||||
|
||||
tabAdapter = new TabAdapter(getChildFragmentManager());
|
||||
binding.viewPager.setAdapter(tabAdapter);
|
||||
binding.tabLayout.setupWithViewPager(binding.viewPager);
|
||||
@@ -583,7 +586,9 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
||||
@Override
|
||||
public void showLoading() {
|
||||
super.showLoading();
|
||||
PicassoHelper.cancelTag(PICASSO_CHANNEL_TAG);
|
||||
CoilUtils.dispose(binding.channelAvatarView);
|
||||
CoilUtils.dispose(binding.channelBannerImage);
|
||||
CoilUtils.dispose(binding.subChannelAvatarView);
|
||||
animate(binding.channelSubscribeButton, false, 100);
|
||||
}
|
||||
|
||||
@@ -594,17 +599,15 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
||||
setInitialData(result.getServiceId(), result.getOriginalUrl(), result.getName());
|
||||
|
||||
if (ImageStrategy.shouldLoadImages() && !result.getBanners().isEmpty()) {
|
||||
PicassoHelper.loadBanner(result.getBanners()).tag(PICASSO_CHANNEL_TAG)
|
||||
.into(binding.channelBannerImage);
|
||||
CoilHelper.INSTANCE.loadBanner(binding.channelBannerImage, result.getBanners());
|
||||
} else {
|
||||
// do not waste space for the banner, if the user disabled images or there is not one
|
||||
binding.channelBannerImage.setImageDrawable(null);
|
||||
}
|
||||
|
||||
PicassoHelper.loadAvatar(result.getAvatars()).tag(PICASSO_CHANNEL_TAG)
|
||||
.into(binding.channelAvatarView);
|
||||
PicassoHelper.loadAvatar(result.getParentChannelAvatars()).tag(PICASSO_CHANNEL_TAG)
|
||||
.into(binding.subChannelAvatarView);
|
||||
CoilHelper.INSTANCE.loadAvatar(binding.channelAvatarView, result.getAvatars());
|
||||
CoilHelper.INSTANCE.loadAvatar(binding.subChannelAvatarView,
|
||||
result.getParentChannelAvatars());
|
||||
|
||||
binding.channelTitleView.setText(result.getName());
|
||||
binding.channelSubscriberView.setVisibility(View.VISIBLE);
|
||||
@@ -652,8 +655,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
||||
return;
|
||||
}
|
||||
|
||||
binding.errorContentNotSupported.setVisibility(View.VISIBLE);
|
||||
binding.channelKaomoji.setText("(︶︹︺)");
|
||||
binding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f);
|
||||
binding.emptyStateView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
|
||||
import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
|
||||
import org.schabi.newpipe.util.ChannelTabHelper;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.PlayButtonHelper;
|
||||
@@ -79,6 +80,12 @@ public class ChannelTabFragment extends BaseListInfoFragment<InfoItem, ChannelTa
|
||||
return inflater.inflate(R.layout.fragment_channel_tab, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
|
||||
super.onViewCreated(rootView, savedInstanceState);
|
||||
EmptyStateUtil.setEmptyStateComposable(rootView.findViewById(R.id.empty_state_view));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
package org.schabi.newpipe.fragments.list.comments;
|
||||
|
||||
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.core.text.HtmlCompat;
|
||||
|
||||
import com.evernote.android.state.State;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.CommentRepliesHeaderBinding;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.info_list.ItemViewMode;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.text.TextLinkifier;
|
||||
import org.schabi.newpipe.util.text.LongPressLinkMovementMethod;
|
||||
|
||||
import java.util.Queue;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
|
||||
public final class CommentRepliesFragment
|
||||
extends BaseListInfoFragment<CommentsInfoItem, CommentRepliesInfo> {
|
||||
|
||||
public static final String TAG = CommentRepliesFragment.class.getSimpleName();
|
||||
|
||||
@State
|
||||
CommentsInfoItem commentsInfoItem; // the comment to show replies of
|
||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Constructors and lifecycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
// only called by the Android framework, after which readFrom is called and restores all data
|
||||
public CommentRepliesFragment() {
|
||||
super(UserAction.REQUESTED_COMMENT_REPLIES);
|
||||
}
|
||||
|
||||
public CommentRepliesFragment(@NonNull final CommentsInfoItem commentsInfoItem) {
|
||||
this();
|
||||
this.commentsInfoItem = commentsInfoItem;
|
||||
// setting "" as title since the title will be properly set right after
|
||||
setInitialData(commentsInfoItem.getServiceId(), commentsInfoItem.getUrl(), "");
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(@NonNull final LayoutInflater inflater,
|
||||
@Nullable final ViewGroup container,
|
||||
@Nullable final Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_comments, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
disposables.clear();
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Supplier<View> getListHeaderSupplier() {
|
||||
return () -> {
|
||||
final CommentRepliesHeaderBinding binding = CommentRepliesHeaderBinding
|
||||
.inflate(activity.getLayoutInflater(), itemsList, false);
|
||||
final CommentsInfoItem item = commentsInfoItem;
|
||||
|
||||
// load the author avatar
|
||||
PicassoHelper.loadAvatar(item.getUploaderAvatars()).into(binding.authorAvatar);
|
||||
binding.authorAvatar.setVisibility(ImageStrategy.shouldLoadImages()
|
||||
? View.VISIBLE : View.GONE);
|
||||
|
||||
// setup author name and comment date
|
||||
binding.authorName.setText(item.getUploaderName());
|
||||
binding.uploadDate.setText(Localization.relativeTimeOrTextual(
|
||||
getContext(), item.getUploadDate(), item.getTextualUploadDate()));
|
||||
binding.authorTouchArea.setOnClickListener(
|
||||
v -> NavigationHelper.openCommentAuthorIfPresent(requireActivity(), item));
|
||||
|
||||
// setup like count, hearted and pinned
|
||||
binding.thumbsUpCount.setText(
|
||||
Localization.likeCount(requireContext(), item.getLikeCount()));
|
||||
// for heartImage goneMarginEnd was used, but there is no way to tell ConstraintLayout
|
||||
// not to use a different margin only when both the next two views are gone
|
||||
((ConstraintLayout.LayoutParams) binding.thumbsUpCount.getLayoutParams())
|
||||
.setMarginEnd(DeviceUtils.dpToPx(
|
||||
(item.isHeartedByUploader() || item.isPinned() ? 8 : 16),
|
||||
requireContext()));
|
||||
binding.heartImage.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE);
|
||||
binding.pinnedImage.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
|
||||
|
||||
// setup comment content
|
||||
TextLinkifier.fromDescription(binding.commentContent, item.getCommentText(),
|
||||
HtmlCompat.FROM_HTML_MODE_LEGACY, getServiceById(item.getServiceId()),
|
||||
item.getUrl(), disposables, null);
|
||||
binding.commentContent.setMovementMethod(LongPressLinkMovementMethod.getInstance());
|
||||
return binding.getRoot();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// State saving
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void writeTo(final Queue<Object> objectsToSave) {
|
||||
super.writeTo(objectsToSave);
|
||||
objectsToSave.add(commentsInfoItem);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void readFrom(@NonNull final Queue<Object> savedObjects) throws Exception {
|
||||
super.readFrom(savedObjects);
|
||||
commentsInfoItem = (CommentsInfoItem) savedObjects.poll();
|
||||
}
|
||||
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Data loading
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected Single<CommentRepliesInfo> loadResult(final boolean forceLoad) {
|
||||
return Single.fromCallable(() -> new CommentRepliesInfo(commentsInfoItem,
|
||||
// the reply count string will be shown as the activity title
|
||||
Localization.replyCount(requireContext(), commentsInfoItem.getReplyCount())));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Single<ListExtractor.InfoItemsPage<CommentsInfoItem>> loadMoreItemsLogic() {
|
||||
// commentsInfoItem.getUrl() should contain the url of the original
|
||||
// ListInfo<CommentsInfoItem>, which should be the stream url
|
||||
return ExtractorHelper.getMoreCommentItems(
|
||||
serviceId, commentsInfoItem.getUrl(), currentNextPage);
|
||||
}
|
||||
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Utils
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected ItemViewMode getItemViewMode() {
|
||||
return ItemViewMode.LIST;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the comment to which the replies are shown
|
||||
*/
|
||||
public CommentsInfoItem getCommentsInfoItem() {
|
||||
return commentsInfoItem;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package org.schabi.newpipe.fragments.list.comments;
|
||||
|
||||
import org.schabi.newpipe.extractor.ListInfo;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
public final class CommentRepliesInfo extends ListInfo<CommentsInfoItem> {
|
||||
/**
|
||||
* This class is used to wrap the comment replies page into a ListInfo object.
|
||||
*
|
||||
* @param comment the comment from which to get replies
|
||||
* @param name will be shown as the fragment title
|
||||
*/
|
||||
public CommentRepliesInfo(final CommentsInfoItem comment, final String name) {
|
||||
super(comment.getServiceId(),
|
||||
new ListLinkHandler("", "", "", Collections.emptyList(), null), name);
|
||||
setNextPage(comment.getReplies());
|
||||
setRelatedItems(Collections.emptyList()); // since it must be non-null
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package org.schabi.newpipe.fragments.list.comments;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfo;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.info_list.ItemViewMode;
|
||||
import org.schabi.newpipe.ktx.ViewUtils;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
|
||||
public class CommentsFragment extends BaseListInfoFragment<CommentsInfoItem, CommentsInfo> {
|
||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||
|
||||
private TextView emptyStateDesc;
|
||||
|
||||
public static CommentsFragment getInstance(final int serviceId, final String url,
|
||||
final String name) {
|
||||
final CommentsFragment instance = new CommentsFragment();
|
||||
instance.setInitialData(serviceId, url, name);
|
||||
return instance;
|
||||
}
|
||||
|
||||
public CommentsFragment() {
|
||||
super(UserAction.REQUESTED_COMMENTS);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
||||
super.initViews(rootView, savedInstanceState);
|
||||
|
||||
emptyStateDesc = rootView.findViewById(R.id.empty_state_desc);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// LifeCycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull final LayoutInflater inflater,
|
||||
@Nullable final ViewGroup container,
|
||||
@Nullable final Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_comments, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
disposables.clear();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Load and handle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected Single<ListExtractor.InfoItemsPage<CommentsInfoItem>> loadMoreItemsLogic() {
|
||||
return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Single<CommentsInfo> loadResult(final boolean forceLoad) {
|
||||
return ExtractorHelper.getCommentsInfo(serviceId, url, forceLoad);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Contract
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void handleResult(@NonNull final CommentsInfo result) {
|
||||
super.handleResult(result);
|
||||
|
||||
emptyStateDesc.setText(
|
||||
result.isCommentsDisabled()
|
||||
? R.string.comments_are_disabled
|
||||
: R.string.no_comments);
|
||||
|
||||
ViewUtils.slideUp(requireView(), 120, 150, 0.06f);
|
||||
disposables.clear();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Utils
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void setTitle(final String title) { }
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) { }
|
||||
|
||||
@Override
|
||||
protected ItemViewMode getItemViewMode() {
|
||||
return ItemViewMode.LIST;
|
||||
}
|
||||
|
||||
public boolean scrollToComment(final CommentsInfoItem comment) {
|
||||
final int position = infoListAdapter.getItemsList().indexOf(comment);
|
||||
if (position < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
itemsList.scrollToPosition(position);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.schabi.newpipe.fragments.list.comments
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.compose.content
|
||||
import org.schabi.newpipe.ui.components.video.comment.CommentSection
|
||||
import org.schabi.newpipe.ui.theme.AppTheme
|
||||
import org.schabi.newpipe.util.KEY_SERVICE_ID
|
||||
import org.schabi.newpipe.util.KEY_URL
|
||||
|
||||
class CommentsFragment : Fragment() {
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
) = content {
|
||||
AppTheme {
|
||||
Surface {
|
||||
CommentSection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun getInstance(serviceId: Int, url: String?) = CommentsFragment().apply {
|
||||
arguments = bundleOf(KEY_SERVICE_ID to serviceId, KEY_URL to url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.PlayButtonHelper;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.image.CoilHelper;
|
||||
import org.schabi.newpipe.util.text.TextEllipsizer;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -62,6 +62,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import coil3.util.CoilUtils;
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
@@ -71,8 +72,6 @@ import io.reactivex.rxjava3.disposables.Disposable;
|
||||
public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, PlaylistInfo>
|
||||
implements PlaylistControlViewHolder {
|
||||
|
||||
private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG";
|
||||
|
||||
private CompositeDisposable disposables;
|
||||
private Subscription bookmarkReactor;
|
||||
private AtomicBoolean isBookmarkButtonReady;
|
||||
@@ -276,7 +275,7 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
|
||||
animate(headerBinding.getRoot(), false, 200);
|
||||
animateHideRecyclerViewAllowingScrolling(itemsList);
|
||||
|
||||
PicassoHelper.cancelTag(PICASSO_PLAYLIST_TAG);
|
||||
CoilUtils.dispose(headerBinding.uploaderAvatarView);
|
||||
animate(headerBinding.uploaderLayout, false, 200);
|
||||
}
|
||||
|
||||
@@ -327,8 +326,8 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
|
||||
R.drawable.ic_radio)
|
||||
);
|
||||
} else {
|
||||
PicassoHelper.loadAvatar(result.getUploaderAvatars()).tag(PICASSO_PLAYLIST_TAG)
|
||||
.into(headerBinding.uploaderAvatarView);
|
||||
CoilHelper.INSTANCE.loadAvatar(headerBinding.uploaderAvatarView,
|
||||
result.getUploaderAvatars());
|
||||
}
|
||||
|
||||
streamCount = result.getStreamCount();
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.schabi.newpipe.fragments.list.search;
|
||||
import static androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags;
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ui.emptystate.EmptyStateUtil.setEmptyStateComposable;
|
||||
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
|
||||
import static java.util.Arrays.asList;
|
||||
|
||||
@@ -65,6 +66,7 @@ import org.schabi.newpipe.ktx.AnimationType;
|
||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.settings.NewPipeSettings;
|
||||
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
@@ -355,6 +357,8 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
||||
super.initViews(rootView, savedInstanceState);
|
||||
|
||||
setEmptyStateComposable(searchBinding.emptyStateView, EmptyStateSpec.NoSearchResult);
|
||||
|
||||
searchBinding.suggestionsList.setAdapter(suggestionListAdapter);
|
||||
// animations are just strange and useless, since the suggestions keep changing too much
|
||||
searchBinding.suggestionsList.setItemAnimator(null);
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
package org.schabi.newpipe.fragments.list.videos;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.RelatedItemsHeaderBinding;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.info_list.ItemViewMode;
|
||||
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
|
||||
import org.schabi.newpipe.ktx.ViewUtils;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
|
||||
public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, RelatedItemsInfo>
|
||||
implements SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
private static final String INFO_KEY = "related_info_key";
|
||||
|
||||
private RelatedItemsInfo relatedItemsInfo;
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Views
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private RelatedItemsHeaderBinding headerBinding;
|
||||
|
||||
public static RelatedItemsFragment getInstance(final StreamInfo info) {
|
||||
final RelatedItemsFragment instance = new RelatedItemsFragment();
|
||||
instance.setInitialData(info);
|
||||
return instance;
|
||||
}
|
||||
|
||||
public RelatedItemsFragment() {
|
||||
super(UserAction.REQUESTED_STREAM);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// LifeCycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull final LayoutInflater inflater,
|
||||
@Nullable final ViewGroup container,
|
||||
@Nullable final Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_related_items, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
headerBinding = null;
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Supplier<View> getListHeaderSupplier() {
|
||||
if (relatedItemsInfo == null || relatedItemsInfo.getRelatedItems() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
headerBinding = RelatedItemsHeaderBinding
|
||||
.inflate(activity.getLayoutInflater(), itemsList, false);
|
||||
|
||||
final SharedPreferences pref = PreferenceManager
|
||||
.getDefaultSharedPreferences(requireContext());
|
||||
final boolean autoplay = pref.getBoolean(getString(R.string.auto_queue_key), false);
|
||||
headerBinding.autoplaySwitch.setChecked(autoplay);
|
||||
headerBinding.autoplaySwitch.setOnCheckedChangeListener((compoundButton, b) ->
|
||||
PreferenceManager.getDefaultSharedPreferences(requireContext()).edit()
|
||||
.putBoolean(getString(R.string.auto_queue_key), b).apply());
|
||||
|
||||
return headerBinding::getRoot;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Single<ListExtractor.InfoItemsPage<InfoItem>> loadMoreItemsLogic() {
|
||||
return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Contract
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected Single<RelatedItemsInfo> loadResult(final boolean forceLoad) {
|
||||
return Single.fromCallable(() -> relatedItemsInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showLoading() {
|
||||
super.showLoading();
|
||||
if (headerBinding != null) {
|
||||
headerBinding.getRoot().setVisibility(View.INVISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleResult(@NonNull final RelatedItemsInfo result) {
|
||||
super.handleResult(result);
|
||||
|
||||
if (headerBinding != null) {
|
||||
headerBinding.getRoot().setVisibility(View.VISIBLE);
|
||||
}
|
||||
ViewUtils.slideUp(requireView(), 120, 96, 0.06f);
|
||||
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Utils
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void setTitle(final String title) {
|
||||
// Nothing to do - override parent
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
// Nothing to do - override parent
|
||||
}
|
||||
|
||||
private void setInitialData(final StreamInfo info) {
|
||||
super.setInitialData(info.getServiceId(), info.getUrl(), info.getName());
|
||||
if (this.relatedItemsInfo == null) {
|
||||
this.relatedItemsInfo = new RelatedItemsInfo(info);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(@NonNull final Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
outState.putSerializable(INFO_KEY, relatedItemsInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRestoreInstanceState(@NonNull final Bundle savedState) {
|
||||
super.onRestoreInstanceState(savedState);
|
||||
final Serializable serializable = savedState.getSerializable(INFO_KEY);
|
||||
if (serializable instanceof RelatedItemsInfo) {
|
||||
this.relatedItemsInfo = (RelatedItemsInfo) serializable;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
|
||||
final String key) {
|
||||
if (headerBinding != null && getString(R.string.auto_queue_key).equals(key)) {
|
||||
headerBinding.autoplaySwitch.setChecked(sharedPreferences.getBoolean(key, false));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ItemViewMode getItemViewMode() {
|
||||
ItemViewMode mode = super.getItemViewMode();
|
||||
// Only list mode is supported. Either List or card will be used.
|
||||
if (mode != ItemViewMode.LIST && mode != ItemViewMode.CARD) {
|
||||
mode = ItemViewMode.LIST;
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void showInfoItemDialog(final StreamInfoItem item) {
|
||||
// Try and attach the InfoItemDialog to the parent fragment of the RelatedItemsFragment
|
||||
// so that its context is not lost when the RelatedItemsFragment is reinitialized,
|
||||
// e.g. when a new stream is loaded in a parent VideoDetailFragment.
|
||||
final Fragment parentFragment = getParentFragment();
|
||||
if (parentFragment != null) {
|
||||
try {
|
||||
new InfoItemDialog.Builder(
|
||||
parentFragment.getActivity(),
|
||||
parentFragment.getContext(),
|
||||
parentFragment,
|
||||
item
|
||||
).create().show();
|
||||
} catch (final IllegalArgumentException e) {
|
||||
InfoItemDialog.Builder.reportErrorDuringInitialization(e, item);
|
||||
}
|
||||
} else {
|
||||
super.showInfoItemDialog(item);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.schabi.newpipe.fragments.list.videos
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.compose.content
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo
|
||||
import org.schabi.newpipe.ktx.serializable
|
||||
import org.schabi.newpipe.ui.components.video.RelatedItems
|
||||
import org.schabi.newpipe.ui.theme.AppTheme
|
||||
import org.schabi.newpipe.util.KEY_INFO
|
||||
|
||||
class RelatedItemsFragment : Fragment() {
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
) = content {
|
||||
AppTheme {
|
||||
Surface {
|
||||
RelatedItems(requireArguments().serializable<StreamInfo>(KEY_INFO)!!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun getInstance(info: StreamInfo) = RelatedItemsFragment().apply {
|
||||
arguments = bundleOf(KEY_INFO to info)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package org.schabi.newpipe.fragments.list.videos;
|
||||
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.ListInfo;
|
||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
|
||||
public final class RelatedItemsInfo extends ListInfo<InfoItem> {
|
||||
/**
|
||||
* This class is used to wrap the related items of a StreamInfo into a ListInfo object.
|
||||
*
|
||||
* @param info the stream info from which to get related items
|
||||
*/
|
||||
public RelatedItemsInfo(final StreamInfo info) {
|
||||
super(info.getServiceId(), new ListLinkHandler(info.getOriginalUrl(), info.getUrl(),
|
||||
info.getId(), Collections.emptyList(), null), info.getName());
|
||||
setRelatedItems(new ArrayList<>(info.getRelatedItems()));
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.CommentInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.InfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder;
|
||||
@@ -75,21 +74,16 @@ public class InfoItemBuilder {
|
||||
private InfoItemHolder holderFromInfoType(@NonNull final ViewGroup parent,
|
||||
@NonNull final InfoItem.InfoType infoType,
|
||||
final boolean useMiniVariant) {
|
||||
switch (infoType) {
|
||||
case STREAM:
|
||||
return useMiniVariant ? new StreamMiniInfoItemHolder(this, parent)
|
||||
: new StreamInfoItemHolder(this, parent);
|
||||
case CHANNEL:
|
||||
return useMiniVariant ? new ChannelMiniInfoItemHolder(this, parent)
|
||||
: new ChannelInfoItemHolder(this, parent);
|
||||
case PLAYLIST:
|
||||
return useMiniVariant ? new PlaylistMiniInfoItemHolder(this, parent)
|
||||
: new PlaylistInfoItemHolder(this, parent);
|
||||
case COMMENT:
|
||||
return new CommentInfoItemHolder(this, parent);
|
||||
default:
|
||||
throw new RuntimeException("InfoType not expected = " + infoType.name());
|
||||
}
|
||||
return switch (infoType) {
|
||||
case STREAM -> useMiniVariant ? new StreamMiniInfoItemHolder(this, parent)
|
||||
: new StreamInfoItemHolder(this, parent);
|
||||
case CHANNEL -> useMiniVariant ? new ChannelMiniInfoItemHolder(this, parent)
|
||||
: new ChannelInfoItemHolder(this, parent);
|
||||
case PLAYLIST -> useMiniVariant ? new PlaylistMiniInfoItemHolder(this, parent)
|
||||
: new PlaylistInfoItemHolder(this, parent);
|
||||
case COMMENT ->
|
||||
throw new IllegalArgumentException("Comments should be rendered using Compose");
|
||||
};
|
||||
}
|
||||
|
||||
public Context getContext() {
|
||||
|
||||
@@ -21,7 +21,6 @@ import org.schabi.newpipe.info_list.holder.ChannelCardInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.ChannelGridInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.CommentInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.InfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.PlaylistCardInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder;
|
||||
@@ -283,46 +282,32 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
||||
Log.d(TAG, "onCreateViewHolder() called with: "
|
||||
+ "parent = [" + parent + "], type = [" + type + "]");
|
||||
}
|
||||
switch (type) {
|
||||
return switch (type) {
|
||||
// #4475 and #3368
|
||||
// Always create a new instance otherwise the same instance
|
||||
// is sometimes reused which causes a crash
|
||||
case HEADER_TYPE:
|
||||
return new HFHolder(headerSupplier.get());
|
||||
case FOOTER_TYPE:
|
||||
return new HFHolder(PignateFooterBinding
|
||||
.inflate(layoutInflater, parent, false)
|
||||
.getRoot()
|
||||
);
|
||||
case MINI_STREAM_HOLDER_TYPE:
|
||||
return new StreamMiniInfoItemHolder(infoItemBuilder, parent);
|
||||
case STREAM_HOLDER_TYPE:
|
||||
return new StreamInfoItemHolder(infoItemBuilder, parent);
|
||||
case GRID_STREAM_HOLDER_TYPE:
|
||||
return new StreamGridInfoItemHolder(infoItemBuilder, parent);
|
||||
case CARD_STREAM_HOLDER_TYPE:
|
||||
return new StreamCardInfoItemHolder(infoItemBuilder, parent);
|
||||
case MINI_CHANNEL_HOLDER_TYPE:
|
||||
return new ChannelMiniInfoItemHolder(infoItemBuilder, parent);
|
||||
case CHANNEL_HOLDER_TYPE:
|
||||
return new ChannelInfoItemHolder(infoItemBuilder, parent);
|
||||
case CARD_CHANNEL_HOLDER_TYPE:
|
||||
return new ChannelCardInfoItemHolder(infoItemBuilder, parent);
|
||||
case GRID_CHANNEL_HOLDER_TYPE:
|
||||
return new ChannelGridInfoItemHolder(infoItemBuilder, parent);
|
||||
case MINI_PLAYLIST_HOLDER_TYPE:
|
||||
return new PlaylistMiniInfoItemHolder(infoItemBuilder, parent);
|
||||
case PLAYLIST_HOLDER_TYPE:
|
||||
return new PlaylistInfoItemHolder(infoItemBuilder, parent);
|
||||
case GRID_PLAYLIST_HOLDER_TYPE:
|
||||
return new PlaylistGridInfoItemHolder(infoItemBuilder, parent);
|
||||
case CARD_PLAYLIST_HOLDER_TYPE:
|
||||
return new PlaylistCardInfoItemHolder(infoItemBuilder, parent);
|
||||
case COMMENT_HOLDER_TYPE:
|
||||
return new CommentInfoItemHolder(infoItemBuilder, parent);
|
||||
default:
|
||||
return new FallbackViewHolder(new View(parent.getContext()));
|
||||
}
|
||||
case HEADER_TYPE -> new HFHolder(headerSupplier.get());
|
||||
case FOOTER_TYPE -> new HFHolder(PignateFooterBinding
|
||||
.inflate(layoutInflater, parent, false)
|
||||
.getRoot()
|
||||
);
|
||||
case MINI_STREAM_HOLDER_TYPE -> new StreamMiniInfoItemHolder(infoItemBuilder, parent);
|
||||
case STREAM_HOLDER_TYPE -> new StreamInfoItemHolder(infoItemBuilder, parent);
|
||||
case GRID_STREAM_HOLDER_TYPE -> new StreamGridInfoItemHolder(infoItemBuilder, parent);
|
||||
case CARD_STREAM_HOLDER_TYPE -> new StreamCardInfoItemHolder(infoItemBuilder, parent);
|
||||
case MINI_CHANNEL_HOLDER_TYPE -> new ChannelMiniInfoItemHolder(infoItemBuilder, parent);
|
||||
case CHANNEL_HOLDER_TYPE -> new ChannelInfoItemHolder(infoItemBuilder, parent);
|
||||
case CARD_CHANNEL_HOLDER_TYPE -> new ChannelCardInfoItemHolder(infoItemBuilder, parent);
|
||||
case GRID_CHANNEL_HOLDER_TYPE -> new ChannelGridInfoItemHolder(infoItemBuilder, parent);
|
||||
case MINI_PLAYLIST_HOLDER_TYPE ->
|
||||
new PlaylistMiniInfoItemHolder(infoItemBuilder, parent);
|
||||
case PLAYLIST_HOLDER_TYPE -> new PlaylistInfoItemHolder(infoItemBuilder, parent);
|
||||
case GRID_PLAYLIST_HOLDER_TYPE ->
|
||||
new PlaylistGridInfoItemHolder(infoItemBuilder, parent);
|
||||
case CARD_PLAYLIST_HOLDER_TYPE ->
|
||||
new PlaylistCardInfoItemHolder(infoItemBuilder, parent);
|
||||
default -> new FallbackViewHolder(new View(parent.getContext()));
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
package org.schabi.newpipe.info_list
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import com.xwray.groupie.GroupieViewHolder
|
||||
import com.xwray.groupie.Item
|
||||
import com.xwray.groupie.viewbinding.BindableItem
|
||||
import com.xwray.groupie.viewbinding.GroupieViewHolder
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.databinding.ItemStreamSegmentBinding
|
||||
import org.schabi.newpipe.extractor.stream.StreamSegment
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.image.PicassoHelper
|
||||
import org.schabi.newpipe.util.image.CoilHelper
|
||||
|
||||
class StreamSegmentItem(
|
||||
private val item: StreamSegment,
|
||||
private val onClick: StreamSegmentAdapter.StreamSegmentListener
|
||||
) : Item<GroupieViewHolder>() {
|
||||
) : BindableItem<ItemStreamSegmentBinding>() {
|
||||
|
||||
companion object {
|
||||
const val PAYLOAD_SELECT = 1
|
||||
@@ -21,31 +20,32 @@ class StreamSegmentItem(
|
||||
|
||||
var isSelected = false
|
||||
|
||||
override fun bind(viewHolder: GroupieViewHolder, position: Int) {
|
||||
item.previewUrl?.let {
|
||||
PicassoHelper.loadThumbnail(it)
|
||||
.into(viewHolder.root.findViewById<ImageView>(R.id.previewImage))
|
||||
}
|
||||
viewHolder.root.findViewById<TextView>(R.id.textViewTitle).text = item.title
|
||||
override fun bind(viewBinding: ItemStreamSegmentBinding, position: Int) {
|
||||
CoilHelper.loadThumbnail(viewBinding.previewImage, item.previewUrl)
|
||||
viewBinding.textViewTitle.text = item.title
|
||||
if (item.channelName == null) {
|
||||
viewHolder.root.findViewById<TextView>(R.id.textViewChannel).visibility = View.GONE
|
||||
viewBinding.textViewChannel.visibility = View.GONE
|
||||
// When the channel name is displayed there is less space
|
||||
// and thus the segment title needs to be only one line height.
|
||||
// But when there is no channel name displayed, the title can be two lines long.
|
||||
// The default maxLines value is set to 1 to display all elements in the AS preview,
|
||||
viewHolder.root.findViewById<TextView>(R.id.textViewTitle).maxLines = 2
|
||||
viewBinding.textViewTitle.maxLines = 2
|
||||
} else {
|
||||
viewHolder.root.findViewById<TextView>(R.id.textViewChannel).text = item.channelName
|
||||
viewHolder.root.findViewById<TextView>(R.id.textViewChannel).visibility = View.VISIBLE
|
||||
viewBinding.textViewChannel.text = item.channelName
|
||||
viewBinding.textViewChannel.visibility = View.VISIBLE
|
||||
}
|
||||
viewHolder.root.findViewById<TextView>(R.id.textViewStartSeconds).text =
|
||||
viewBinding.textViewStartSeconds.text =
|
||||
Localization.getDurationString(item.startTimeSeconds.toLong())
|
||||
viewHolder.root.setOnClickListener { onClick.onItemClick(this, item.startTimeSeconds) }
|
||||
viewHolder.root.setOnLongClickListener { onClick.onItemLongClick(this, item.startTimeSeconds); true }
|
||||
viewHolder.root.isSelected = isSelected
|
||||
viewBinding.root.setOnClickListener { onClick.onItemClick(this, item.startTimeSeconds) }
|
||||
viewBinding.root.setOnLongClickListener { onClick.onItemLongClick(this, item.startTimeSeconds); true }
|
||||
viewBinding.root.isSelected = isSelected
|
||||
}
|
||||
|
||||
override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList<Any>) {
|
||||
override fun bind(
|
||||
viewHolder: GroupieViewHolder<ItemStreamSegmentBinding>,
|
||||
position: Int,
|
||||
payloads: MutableList<Any>
|
||||
) {
|
||||
if (payloads.contains(PAYLOAD_SELECT)) {
|
||||
viewHolder.root.isSelected = isSelected
|
||||
return
|
||||
@@ -54,4 +54,6 @@ class StreamSegmentItem(
|
||||
}
|
||||
|
||||
override fun getLayout() = R.layout.item_stream_segment
|
||||
|
||||
override fun initializeViewBinding(view: View) = ItemStreamSegmentBinding.bind(view)
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ public final class InfoItemDialog {
|
||||
* @return the current {@link Builder} instance
|
||||
*/
|
||||
public Builder addEnqueueEntriesIfNeeded() {
|
||||
final PlayerHolder holder = PlayerHolder.getInstance();
|
||||
final PlayerHolder holder = PlayerHolder.INSTANCE;
|
||||
if (holder.isPlayQueueReady()) {
|
||||
addEntry(StreamDialogDefaultEntry.ENQUEUE);
|
||||
|
||||
@@ -346,7 +346,7 @@ public final class InfoItemDialog {
|
||||
|
||||
public static void reportErrorDuringInitialization(final Throwable throwable,
|
||||
final InfoItem item) {
|
||||
ErrorUtil.showSnackbar(App.getApp().getBaseContext(), new ErrorInfo(
|
||||
ErrorUtil.showSnackbar(App.getInstance().getBaseContext(), new ErrorInfo(
|
||||
throwable,
|
||||
UserAction.OPEN_INFO_ITEM_DIALOG,
|
||||
"none",
|
||||
|
||||
@@ -41,10 +41,11 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
* </p>
|
||||
*/
|
||||
public enum StreamDialogDefaultEntry {
|
||||
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) ->
|
||||
fetchUploaderUrlIfSparse(fragment.requireContext(), item.getServiceId(), item.getUrl(),
|
||||
item.getUploaderUrl(), url -> openChannelFragment(fragment, item, url))
|
||||
),
|
||||
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) -> {
|
||||
final var activity = fragment.requireActivity();
|
||||
fetchUploaderUrlIfSparse(activity, item.getServiceId(), item.getUrl(),
|
||||
item.getUploaderUrl(), url -> openChannelFragment(activity, item, url));
|
||||
}),
|
||||
|
||||
/**
|
||||
* Enqueues the stream automatically to the current PlayerType.
|
||||
|
||||
@@ -13,8 +13,8 @@ import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
||||
import org.schabi.newpipe.extractor.utils.Utils;
|
||||
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.image.CoilHelper;
|
||||
|
||||
public class ChannelMiniInfoItemHolder extends InfoItemHolder {
|
||||
private final ImageView itemThumbnailView;
|
||||
@@ -56,7 +56,7 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder {
|
||||
itemAdditionalDetailView.setText(getDetailLine(item));
|
||||
}
|
||||
|
||||
PicassoHelper.loadAvatar(item.getThumbnails()).into(itemThumbnailView);
|
||||
CoilHelper.INSTANCE.loadAvatar(itemThumbnailView, item.getThumbnails());
|
||||
|
||||
itemView.setOnClickListener(view -> {
|
||||
if (itemBuilder.getOnChannelSelectedListener() != null) {
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
package org.schabi.newpipe.info_list.holder;
|
||||
|
||||
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
|
||||
import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine;
|
||||
|
||||
import android.text.Spanned;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.text.style.ClickableSpan;
|
||||
import android.text.style.URLSpan;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.text.TextEllipsizer;
|
||||
|
||||
public class CommentInfoItemHolder extends InfoItemHolder {
|
||||
|
||||
private static final int COMMENT_DEFAULT_LINES = 2;
|
||||
private final int commentHorizontalPadding;
|
||||
private final int commentVerticalPadding;
|
||||
|
||||
private final RelativeLayout itemRoot;
|
||||
private final ImageView itemThumbnailView;
|
||||
private final TextView itemContentView;
|
||||
private final ImageView itemThumbsUpView;
|
||||
private final TextView itemLikesCountView;
|
||||
private final TextView itemTitleView;
|
||||
private final ImageView itemHeartView;
|
||||
private final ImageView itemPinnedView;
|
||||
private final Button repliesButton;
|
||||
|
||||
@NonNull
|
||||
private final TextEllipsizer textEllipsizer;
|
||||
|
||||
public CommentInfoItemHolder(final InfoItemBuilder infoItemBuilder,
|
||||
final ViewGroup parent) {
|
||||
super(infoItemBuilder, R.layout.list_comment_item, parent);
|
||||
|
||||
itemRoot = itemView.findViewById(R.id.itemRoot);
|
||||
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
|
||||
itemContentView = itemView.findViewById(R.id.itemCommentContentView);
|
||||
itemThumbsUpView = itemView.findViewById(R.id.detail_thumbs_up_img_view);
|
||||
itemLikesCountView = itemView.findViewById(R.id.detail_thumbs_up_count_view);
|
||||
itemTitleView = itemView.findViewById(R.id.itemTitleView);
|
||||
itemHeartView = itemView.findViewById(R.id.detail_heart_image_view);
|
||||
itemPinnedView = itemView.findViewById(R.id.detail_pinned_view);
|
||||
repliesButton = itemView.findViewById(R.id.replies_button);
|
||||
|
||||
commentHorizontalPadding = (int) infoItemBuilder.getContext()
|
||||
.getResources().getDimension(R.dimen.comments_horizontal_padding);
|
||||
commentVerticalPadding = (int) infoItemBuilder.getContext()
|
||||
.getResources().getDimension(R.dimen.comments_vertical_padding);
|
||||
|
||||
textEllipsizer = new TextEllipsizer(itemContentView, COMMENT_DEFAULT_LINES, null);
|
||||
textEllipsizer.setStateChangeListener(isEllipsized -> {
|
||||
if (Boolean.TRUE.equals(isEllipsized)) {
|
||||
denyLinkFocus();
|
||||
} else {
|
||||
determineMovementMethod();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateFromItem(final InfoItem infoItem,
|
||||
final HistoryRecordManager historyRecordManager) {
|
||||
if (!(infoItem instanceof CommentsInfoItem)) {
|
||||
return;
|
||||
}
|
||||
final CommentsInfoItem item = (CommentsInfoItem) infoItem;
|
||||
|
||||
|
||||
// load the author avatar
|
||||
PicassoHelper.loadAvatar(item.getUploaderAvatars()).into(itemThumbnailView);
|
||||
if (ImageStrategy.shouldLoadImages()) {
|
||||
itemThumbnailView.setVisibility(View.VISIBLE);
|
||||
itemRoot.setPadding(commentVerticalPadding, commentVerticalPadding,
|
||||
commentVerticalPadding, commentVerticalPadding);
|
||||
} else {
|
||||
itemThumbnailView.setVisibility(View.GONE);
|
||||
itemRoot.setPadding(commentHorizontalPadding, commentVerticalPadding,
|
||||
commentHorizontalPadding, commentVerticalPadding);
|
||||
}
|
||||
itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item));
|
||||
|
||||
// setup the top row, with pinned icon, author name and comment date
|
||||
itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
|
||||
final String uploaderName = Localization.localizeUserName(item.getUploaderName());
|
||||
itemTitleView.setText(Localization.concatenateStrings(
|
||||
uploaderName,
|
||||
Localization.relativeTimeOrTextual(
|
||||
itemBuilder.getContext(),
|
||||
item.getUploadDate(),
|
||||
item.getTextualUploadDate())));
|
||||
|
||||
// setup bottom row, with likes, heart and replies button
|
||||
itemLikesCountView.setText(
|
||||
Localization.likeCount(itemBuilder.getContext(), item.getLikeCount()));
|
||||
|
||||
itemHeartView.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE);
|
||||
|
||||
final boolean hasReplies = item.getReplies() != null;
|
||||
repliesButton.setOnClickListener(hasReplies ? v -> openCommentReplies(item) : null);
|
||||
repliesButton.setVisibility(hasReplies ? View.VISIBLE : View.GONE);
|
||||
repliesButton.setText(hasReplies
|
||||
? Localization.replyCount(itemBuilder.getContext(), item.getReplyCount()) : "");
|
||||
((RelativeLayout.LayoutParams) itemThumbsUpView.getLayoutParams()).topMargin =
|
||||
hasReplies ? 0 : DeviceUtils.dpToPx(6, itemBuilder.getContext());
|
||||
|
||||
|
||||
// setup comment content and click listeners to expand/ellipsize it
|
||||
textEllipsizer.setStreamingService(getServiceById(item.getServiceId()));
|
||||
textEllipsizer.setStreamUrl(item.getUrl());
|
||||
textEllipsizer.setContent(item.getCommentText());
|
||||
textEllipsizer.ellipsize();
|
||||
|
||||
//noinspection ClickableViewAccessibility
|
||||
itemContentView.setOnTouchListener((v, event) -> {
|
||||
final CharSequence text = itemContentView.getText();
|
||||
if (text instanceof Spanned buffer) {
|
||||
final int action = event.getAction();
|
||||
|
||||
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
|
||||
final int offset = getOffsetForHorizontalLine(itemContentView, event);
|
||||
final var links = buffer.getSpans(offset, offset, ClickableSpan.class);
|
||||
|
||||
if (links.length != 0) {
|
||||
if (action == MotionEvent.ACTION_UP) {
|
||||
links[0].onClick(itemContentView);
|
||||
}
|
||||
// we handle events that intersect links, so return true
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
itemView.setOnClickListener(view -> {
|
||||
textEllipsizer.toggle();
|
||||
if (itemBuilder.getOnCommentsSelectedListener() != null) {
|
||||
itemBuilder.getOnCommentsSelectedListener().selected(item);
|
||||
}
|
||||
});
|
||||
|
||||
itemView.setOnLongClickListener(view -> {
|
||||
if (DeviceUtils.isTv(itemBuilder.getContext())) {
|
||||
openCommentAuthor(item);
|
||||
} else {
|
||||
final CharSequence text = itemContentView.getText();
|
||||
if (text != null) {
|
||||
ShareUtils.copyToClipboard(itemBuilder.getContext(), text.toString());
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private void openCommentAuthor(@NonNull final CommentsInfoItem item) {
|
||||
NavigationHelper.openCommentAuthorIfPresent((FragmentActivity) itemBuilder.getContext(),
|
||||
item);
|
||||
}
|
||||
|
||||
private void openCommentReplies(@NonNull final CommentsInfoItem item) {
|
||||
NavigationHelper.openCommentRepliesFragment((FragmentActivity) itemBuilder.getContext(),
|
||||
item);
|
||||
}
|
||||
|
||||
private void allowLinkFocus() {
|
||||
itemContentView.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
}
|
||||
|
||||
private void denyLinkFocus() {
|
||||
itemContentView.setMovementMethod(null);
|
||||
}
|
||||
|
||||
private boolean shouldFocusLinks() {
|
||||
if (itemView.isInTouchMode()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final URLSpan[] urls = itemContentView.getUrls();
|
||||
|
||||
return urls != null && urls.length != 0;
|
||||
}
|
||||
|
||||
private void determineMovementMethod() {
|
||||
if (shouldFocusLinks()) {
|
||||
allowLinkFocus();
|
||||
} else {
|
||||
denyLinkFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
|
||||
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.image.CoilHelper;
|
||||
|
||||
public class PlaylistMiniInfoItemHolder extends InfoItemHolder {
|
||||
public final ImageView itemThumbnailView;
|
||||
@@ -46,7 +46,7 @@ public class PlaylistMiniInfoItemHolder extends InfoItemHolder {
|
||||
.localizeStreamCountMini(itemStreamCountView.getContext(), item.getStreamCount()));
|
||||
itemUploaderView.setText(item.getUploaderName());
|
||||
|
||||
PicassoHelper.loadPlaylistThumbnail(item.getThumbnails()).into(itemThumbnailView);
|
||||
CoilHelper.INSTANCE.loadPlaylistThumbnail(itemThumbnailView, item.getThumbnails());
|
||||
|
||||
itemView.setOnClickListener(view -> {
|
||||
if (itemBuilder.getOnPlaylistSelectedListener() != null) {
|
||||
|
||||
@@ -16,8 +16,8 @@ import org.schabi.newpipe.ktx.ViewUtils;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.DependentPreferenceHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.StreamTypeUtil;
|
||||
import org.schabi.newpipe.util.image.CoilHelper;
|
||||
import org.schabi.newpipe.views.AnimatedProgressBar;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -64,8 +64,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
||||
StreamStateEntity state2 = null;
|
||||
if (DependentPreferenceHelper
|
||||
.getPositionsInListsEnabled(itemProgressView.getContext())) {
|
||||
state2 = historyRecordManager.loadStreamState(infoItem)
|
||||
.blockingGet()[0];
|
||||
state2 = historyRecordManager.loadStreamState(infoItem).blockingGet();
|
||||
}
|
||||
if (state2 != null) {
|
||||
itemProgressView.setVisibility(View.VISIBLE);
|
||||
@@ -87,7 +86,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
||||
}
|
||||
|
||||
// Default thumbnail is shown on error, while loading and if the url is empty
|
||||
PicassoHelper.loadThumbnail(item.getThumbnails()).into(itemThumbnailView);
|
||||
CoilHelper.INSTANCE.loadThumbnail(itemThumbnailView, item.getThumbnails());
|
||||
|
||||
itemView.setOnClickListener(view -> {
|
||||
if (itemBuilder.getOnStreamSelectedListener() != null) {
|
||||
@@ -120,7 +119,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
||||
if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())) {
|
||||
state = historyRecordManager
|
||||
.loadStreamState(infoItem)
|
||||
.blockingGet()[0];
|
||||
.blockingGet();
|
||||
}
|
||||
if (state != null && item.getDuration() > 0
|
||||
&& !StreamTypeUtil.isLiveStream(item.getStreamType())) {
|
||||
|
||||
13
app/src/main/java/org/schabi/newpipe/ktx/Bitmap.kt
Normal file
13
app/src/main/java/org/schabi/newpipe/ktx/Bitmap.kt
Normal file
@@ -0,0 +1,13 @@
|
||||
package org.schabi.newpipe.ktx
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import androidx.core.graphics.BitmapCompat
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
inline fun Bitmap.scale(
|
||||
width: Int,
|
||||
height: Int,
|
||||
srcRect: Rect? = null,
|
||||
scaleInLinearSpace: Boolean = true,
|
||||
) = BitmapCompat.createScaledBitmap(this, width, height, srcRect, scaleInLinearSpace)
|
||||
@@ -1,11 +1,11 @@
|
||||
package org.schabi.newpipe.ktx
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import androidx.core.os.BundleCompat
|
||||
import java.io.Serializable
|
||||
|
||||
inline fun <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? {
|
||||
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
|
||||
inline fun <reified T : Serializable> Bundle.serializable(key: String?): T? {
|
||||
return BundleCompat.getSerializable(this, key, T::class.java)
|
||||
}
|
||||
|
||||
fun Bundle?.toDebugString(): String {
|
||||
|
||||
13
app/src/main/java/org/schabi/newpipe/ktx/Context.kt
Normal file
13
app/src/main/java/org/schabi/newpipe/ktx/Context.kt
Normal file
@@ -0,0 +1,13 @@
|
||||
package org.schabi.newpipe.ktx
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
|
||||
tailrec fun Context.findFragmentActivity(): FragmentActivity {
|
||||
return when (this) {
|
||||
is FragmentActivity -> this
|
||||
is ContextWrapper -> baseContext.findFragmentActivity()
|
||||
else -> throw IllegalStateException("Unable to find FragmentActivity")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import android.view.ViewGroup;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.compose.ui.platform.ComposeView;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
@@ -38,6 +39,8 @@ import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder;
|
||||
import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder;
|
||||
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
|
||||
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
|
||||
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
|
||||
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.OnClickGesture;
|
||||
import org.schabi.newpipe.util.debounce.DebounceSavable;
|
||||
@@ -123,6 +126,8 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
super.initViews(rootView, savedInstanceState);
|
||||
|
||||
itemListAdapter.setUseItemHandle(true);
|
||||
final ComposeView emptyView = rootView.findViewById(R.id.empty_state_view);
|
||||
EmptyStateUtil.setEmptyStateComposable(emptyView, EmptyStateSpec.NoBookmarkedPlaylist);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -74,6 +74,7 @@ import org.schabi.newpipe.ktx.slideUp
|
||||
import org.schabi.newpipe.local.feed.item.StreamItem
|
||||
import org.schabi.newpipe.local.feed.service.FeedLoadService
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable
|
||||
import org.schabi.newpipe.util.DeviceUtils
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.NavigationHelper
|
||||
@@ -132,6 +133,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) {
|
||||
// super.onViewCreated() calls initListeners() which require the binding to be initialized
|
||||
_feedBinding = FragmentFeedBinding.bind(rootView)
|
||||
feedBinding.emptyStateView.setEmptyStateComposable()
|
||||
super.onViewCreated(rootView, savedInstanceState)
|
||||
|
||||
val factory = FeedViewModel.getFactory(requireContext(), groupId)
|
||||
@@ -202,6 +204,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
// Menu
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
|
||||
@@ -212,6 +215,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
inflater.inflate(R.menu.menu_feed_fragment, menu)
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == R.id.menu_item_feed_help) {
|
||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
@@ -253,7 +257,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
viewModel.getShowFutureItemsFromPreferences()
|
||||
)
|
||||
|
||||
AlertDialog.Builder(context!!)
|
||||
AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.feed_hide_streams_title)
|
||||
.setMultiChoiceItems(dialogItems, checkedDialogItems) { _, which, isChecked ->
|
||||
checkedDialogItems[which] = isChecked
|
||||
@@ -267,6 +271,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
.show()
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onDestroyOptionsMenu() {
|
||||
super.onDestroyOptionsMenu()
|
||||
if (
|
||||
|
||||
@@ -165,7 +165,7 @@ class FeedViewModel(
|
||||
fun getFactory(context: Context, groupId: Long) = viewModelFactory {
|
||||
initializer {
|
||||
FeedViewModel(
|
||||
App.getApp(),
|
||||
App.instance,
|
||||
groupId,
|
||||
// Read initial value from preferences
|
||||
getShowPlayedItemsFromPreferences(context.applicationContext),
|
||||
|
||||
@@ -19,7 +19,7 @@ import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_STREAM
|
||||
import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.StreamTypeUtil
|
||||
import org.schabi.newpipe.util.image.PicassoHelper
|
||||
import org.schabi.newpipe.util.image.CoilHelper
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.function.Consumer
|
||||
|
||||
@@ -101,7 +101,7 @@ data class StreamItem(
|
||||
viewBinding.itemProgressView.visibility = View.GONE
|
||||
}
|
||||
|
||||
PicassoHelper.loadThumbnail(stream.thumbnailUrl).into(viewBinding.itemThumbnailView)
|
||||
CoilHelper.loadThumbnail(viewBinding.itemThumbnailView, stream.thumbnailUrl)
|
||||
|
||||
if (itemVersion != ItemVersion.MINI) {
|
||||
viewBinding.itemAdditionalDetails.text =
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
@@ -16,20 +15,17 @@ import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.squareup.picasso.Picasso
|
||||
import com.squareup.picasso.Target
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.service.FeedUpdateInfo
|
||||
import org.schabi.newpipe.util.NavigationHelper
|
||||
import org.schabi.newpipe.util.image.PicassoHelper
|
||||
import org.schabi.newpipe.util.image.CoilHelper
|
||||
|
||||
/**
|
||||
* Helper for everything related to show notifications about new streams to the user.
|
||||
*/
|
||||
class NotificationHelper(val context: Context) {
|
||||
private val manager = NotificationManagerCompat.from(context)
|
||||
private val iconLoadingTargets = ArrayList<Target>()
|
||||
|
||||
/**
|
||||
* Show notifications for new streams from a single channel. The individual notifications are
|
||||
@@ -68,61 +64,31 @@ class NotificationHelper(val context: Context) {
|
||||
summaryBuilder.setStyle(style)
|
||||
|
||||
// open the channel page when clicking on the summary notification
|
||||
val intent = NavigationHelper
|
||||
.getChannelIntent(context, data.serviceId, data.url)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
summaryBuilder.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
context,
|
||||
data.pseudoId,
|
||||
NavigationHelper
|
||||
.getChannelIntent(context, data.serviceId, data.url)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
0,
|
||||
false
|
||||
)
|
||||
PendingIntentCompat.getActivity(context, data.pseudoId, intent, 0, false)
|
||||
)
|
||||
|
||||
// a Target is like a listener for image loading events
|
||||
val target = object : Target {
|
||||
override fun onBitmapLoaded(bitmap: Bitmap, from: Picasso.LoadedFrom) {
|
||||
// set channel icon only if there is actually one (for Android versions < 7.0)
|
||||
summaryBuilder.setLargeIcon(bitmap)
|
||||
val avatarIcon =
|
||||
CoilHelper.loadBitmapBlocking(context, data.avatarUrl, R.drawable.ic_newpipe_triangle_white)
|
||||
|
||||
// Show individual stream notifications, set channel icon only if there is actually
|
||||
// one
|
||||
showStreamNotifications(newStreams, data.serviceId, data.url, bitmap)
|
||||
// Show summary notification
|
||||
manager.notify(data.pseudoId, summaryBuilder.build())
|
||||
summaryBuilder.setLargeIcon(avatarIcon)
|
||||
|
||||
iconLoadingTargets.remove(this) // allow it to be garbage-collected
|
||||
}
|
||||
|
||||
override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) {
|
||||
// Show individual stream notifications
|
||||
showStreamNotifications(newStreams, data.serviceId, data.url, null)
|
||||
// Show summary notification
|
||||
manager.notify(data.pseudoId, summaryBuilder.build())
|
||||
iconLoadingTargets.remove(this) // allow it to be garbage-collected
|
||||
}
|
||||
|
||||
override fun onPrepareLoad(placeHolderDrawable: Drawable) {
|
||||
// Nothing to do
|
||||
}
|
||||
}
|
||||
|
||||
// add the target to the list to hold a strong reference and prevent it from being garbage
|
||||
// collected, since Picasso only holds weak references to targets
|
||||
iconLoadingTargets.add(target)
|
||||
|
||||
PicassoHelper.loadNotificationIcon(data.avatarUrl).into(target)
|
||||
// Show individual stream notifications, set channel icon only if there is actually one
|
||||
showStreamNotifications(newStreams, data.serviceId, avatarIcon)
|
||||
// Show summary notification
|
||||
manager.notify(data.pseudoId, summaryBuilder.build())
|
||||
}
|
||||
|
||||
private fun showStreamNotifications(
|
||||
newStreams: List<StreamInfoItem>,
|
||||
serviceId: Int,
|
||||
channelUrl: String,
|
||||
channelIcon: Bitmap?
|
||||
) {
|
||||
for (stream in newStreams) {
|
||||
val notification = createStreamNotification(stream, serviceId, channelUrl, channelIcon)
|
||||
val notification = createStreamNotification(stream, serviceId, channelIcon)
|
||||
manager.notify(stream.url.hashCode(), notification)
|
||||
}
|
||||
}
|
||||
@@ -130,7 +96,6 @@ class NotificationHelper(val context: Context) {
|
||||
private fun createStreamNotification(
|
||||
item: StreamInfoItem,
|
||||
serviceId: Int,
|
||||
channelUrl: String,
|
||||
channelIcon: Bitmap?
|
||||
): Notification {
|
||||
return NotificationCompat.Builder(
|
||||
@@ -141,7 +106,7 @@ class NotificationHelper(val context: Context) {
|
||||
.setLargeIcon(channelIcon)
|
||||
.setContentTitle(item.name)
|
||||
.setContentText(item.uploaderName)
|
||||
.setGroup(channelUrl)
|
||||
.setGroup(item.uploaderUrl)
|
||||
.setColor(ContextCompat.getColor(context, R.color.ic_launcher_background))
|
||||
.setColorized(true)
|
||||
.setAutoCancel(true)
|
||||
|
||||
@@ -18,10 +18,13 @@ package org.schabi.newpipe.local.history;
|
||||
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import static org.schabi.newpipe.util.ExtractorHelper.getStreamInfo;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.collection.LongLongPair;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
@@ -45,7 +48,6 @@ import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.local.feed.FeedViewModel;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
@@ -91,47 +93,39 @@ public class HistoryRecordManager {
|
||||
* @param info the item to mark as watched
|
||||
* @return a Maybe containing the ID of the item if successful
|
||||
*/
|
||||
public Maybe<Long> markAsWatched(final StreamInfoItem info) {
|
||||
public Completable markAsWatched(final StreamInfoItem info) {
|
||||
if (!isStreamHistoryEnabled()) {
|
||||
return Maybe.empty();
|
||||
return Completable.complete();
|
||||
}
|
||||
|
||||
final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
return Maybe.fromCallable(() -> database.runInTransaction(() -> {
|
||||
final long streamId;
|
||||
final long duration;
|
||||
// Duration will not exist if the item was loaded with fast mode, so fetch it if empty
|
||||
if (info.getDuration() < 0) {
|
||||
final StreamInfo completeInfo = ExtractorHelper.getStreamInfo(
|
||||
info.getServiceId(),
|
||||
info.getUrl(),
|
||||
false
|
||||
final var remoteInfo = getStreamInfo(info.getServiceId(), info.getUrl(), false)
|
||||
.map(item ->
|
||||
new LongLongPair(item.getDuration(), streamTable.upsert(new StreamEntity(item))));
|
||||
|
||||
return Single.just(info)
|
||||
.filter(item -> item.getDuration() >= 0)
|
||||
.map(item ->
|
||||
new LongLongPair(item.getDuration(), streamTable.upsert(new StreamEntity(item)))
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.blockingGet();
|
||||
duration = completeInfo.getDuration();
|
||||
streamId = streamTable.upsert(new StreamEntity(completeInfo));
|
||||
} else {
|
||||
duration = info.getDuration();
|
||||
streamId = streamTable.upsert(new StreamEntity(info));
|
||||
}
|
||||
.switchIfEmpty(remoteInfo)
|
||||
.flatMapCompletable(pair -> Completable.fromRunnable(() -> {
|
||||
final long duration = pair.getFirst();
|
||||
final long streamId = pair.getSecond();
|
||||
|
||||
// Update the stream progress to the full duration of the video
|
||||
final StreamStateEntity entity = new StreamStateEntity(
|
||||
streamId,
|
||||
duration * 1000
|
||||
);
|
||||
streamStateTable.upsert(entity);
|
||||
// Update the stream progress to the full duration of the video
|
||||
final var entity = new StreamStateEntity(streamId, duration * 1000);
|
||||
streamStateTable.upsert(entity);
|
||||
|
||||
// Add a history entry
|
||||
final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId);
|
||||
if (latestEntry == null) {
|
||||
// never actually viewed: add history entry but with 0 views
|
||||
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 0));
|
||||
} else {
|
||||
return 0L;
|
||||
}
|
||||
})).subscribeOn(Schedulers.io());
|
||||
// Add a history entry
|
||||
final var latestEntry = streamHistoryTable.getLatestEntry(streamId);
|
||||
if (latestEntry == null) {
|
||||
final var currentTime = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
// never actually viewed: add history entry but with 0 views
|
||||
final var entry = new StreamHistoryEntity(streamId, currentTime, 0);
|
||||
streamHistoryTable.insert(entry);
|
||||
}
|
||||
}))
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Maybe<Long> onViewed(final StreamInfo info) {
|
||||
@@ -221,7 +215,7 @@ public class HistoryRecordManager {
|
||||
public Flowable<List<String>> getRelatedSearches(final String query,
|
||||
final int similarQueryLimit,
|
||||
final int uniqueQueryLimit) {
|
||||
return query.length() > 0
|
||||
return !query.isEmpty()
|
||||
? searchHistoryTable.getSimilarEntries(query, similarQueryLimit)
|
||||
: searchHistoryTable.getUniqueEntries(uniqueQueryLimit);
|
||||
}
|
||||
@@ -236,47 +230,31 @@ public class HistoryRecordManager {
|
||||
|
||||
public Maybe<StreamStateEntity> loadStreamState(final PlayQueueItem queueItem) {
|
||||
return queueItem.getStream()
|
||||
.map(info -> streamTable.upsert(new StreamEntity(info)))
|
||||
.flatMapPublisher(streamStateTable::getState)
|
||||
.firstElement()
|
||||
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0)))
|
||||
.flatMapMaybe(this::loadStreamState)
|
||||
.filter(state -> state.isValid(queueItem.getDuration()))
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Maybe<StreamStateEntity> loadStreamState(final StreamInfo info) {
|
||||
return Single.fromCallable(() -> streamTable.upsert(new StreamEntity(info)))
|
||||
.flatMapPublisher(streamStateTable::getState)
|
||||
.firstElement()
|
||||
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0)))
|
||||
.filter(state -> state.isValid(info.getDuration()))
|
||||
.flatMapMaybe(streamStateTable::getState)
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Completable saveStreamState(@NonNull final StreamInfo info, final long progressMillis) {
|
||||
return Completable.fromAction(() -> database.runInTransaction(() -> {
|
||||
final long streamId = streamTable.upsert(new StreamEntity(info));
|
||||
final StreamStateEntity state = new StreamStateEntity(streamId, progressMillis);
|
||||
final var state = new StreamStateEntity(streamId, progressMillis);
|
||||
if (state.isValid(info.getDuration())) {
|
||||
streamStateTable.upsert(state);
|
||||
}
|
||||
})).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Single<StreamStateEntity[]> loadStreamState(final InfoItem info) {
|
||||
return Single.fromCallable(() -> {
|
||||
final List<StreamEntity> entities = streamTable
|
||||
.getStream(info.getServiceId(), info.getUrl()).blockingFirst();
|
||||
if (entities.isEmpty()) {
|
||||
return new StreamStateEntity[]{null};
|
||||
}
|
||||
final List<StreamStateEntity> states = streamStateTable
|
||||
.getState(entities.get(0).getUid()).blockingFirst();
|
||||
if (states.isEmpty()) {
|
||||
return new StreamStateEntity[]{null};
|
||||
}
|
||||
return new StreamStateEntity[]{states.get(0)};
|
||||
}).subscribeOn(Schedulers.io());
|
||||
public Maybe<StreamStateEntity> loadStreamState(final InfoItem info) {
|
||||
return streamTable.getStream(info.getServiceId(), info.getUrl())
|
||||
.flatMap(entity -> streamStateTable.getState(entity.getUid()))
|
||||
.subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Single<List<StreamStateEntity>> loadLocalStreamStateBatch(
|
||||
@@ -295,13 +273,7 @@ public class HistoryRecordManager {
|
||||
result.add(null);
|
||||
continue;
|
||||
}
|
||||
final List<StreamStateEntity> states = streamStateTable.getState(streamId)
|
||||
.blockingFirst();
|
||||
if (states.isEmpty()) {
|
||||
result.add(null);
|
||||
} else {
|
||||
result.add(states.get(0));
|
||||
}
|
||||
result.add(streamStateTable.getState(streamId).blockingGet());
|
||||
}
|
||||
return result;
|
||||
}).subscribeOn(Schedulers.io());
|
||||
|
||||
@@ -8,8 +8,8 @@ import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
|
||||
import org.schabi.newpipe.local.LocalItemBuilder;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.image.CoilHelper;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
@@ -30,17 +30,16 @@ public class LocalPlaylistItemHolder extends PlaylistItemHolder {
|
||||
public void updateFromItem(final LocalItem localItem,
|
||||
final HistoryRecordManager historyRecordManager,
|
||||
final DateTimeFormatter dateTimeFormatter) {
|
||||
if (!(localItem instanceof PlaylistMetadataEntry)) {
|
||||
if (!(localItem instanceof PlaylistMetadataEntry item)) {
|
||||
return;
|
||||
}
|
||||
final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem;
|
||||
|
||||
itemTitleView.setText(item.getOrderingName());
|
||||
itemStreamCountView.setText(Localization.localizeStreamCountMini(
|
||||
itemStreamCountView.getContext(), item.getStreamCount()));
|
||||
itemUploaderView.setVisibility(View.INVISIBLE);
|
||||
|
||||
PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView);
|
||||
CoilHelper.INSTANCE.loadPlaylistThumbnail(itemThumbnailView, item.getThumbnailUrl());
|
||||
|
||||
if (item instanceof PlaylistDuplicatesEntry
|
||||
&& ((PlaylistDuplicatesEntry) item).getTimesStreamIsContained() > 0) {
|
||||
|
||||
@@ -16,8 +16,8 @@ import org.schabi.newpipe.local.LocalItemBuilder;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.DependentPreferenceHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
import org.schabi.newpipe.util.image.CoilHelper;
|
||||
import org.schabi.newpipe.views.AnimatedProgressBar;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
@@ -83,8 +83,8 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder {
|
||||
}
|
||||
|
||||
// Default thumbnail is shown on error, while loading and if the url is empty
|
||||
PicassoHelper.loadThumbnail(item.getStreamEntity().getThumbnailUrl())
|
||||
.into(itemThumbnailView);
|
||||
CoilHelper.INSTANCE.loadThumbnail(itemThumbnailView,
|
||||
item.getStreamEntity().getThumbnailUrl());
|
||||
|
||||
itemView.setOnClickListener(view -> {
|
||||
if (itemBuilder.getOnItemSelectedListener() != null) {
|
||||
|
||||
@@ -16,8 +16,8 @@ import org.schabi.newpipe.local.LocalItemBuilder;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.DependentPreferenceHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
import org.schabi.newpipe.util.image.CoilHelper;
|
||||
import org.schabi.newpipe.views.AnimatedProgressBar;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
@@ -117,8 +117,8 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder {
|
||||
}
|
||||
|
||||
// Default thumbnail is shown on error, while loading and if the url is empty
|
||||
PicassoHelper.loadThumbnail(item.getStreamEntity().getThumbnailUrl())
|
||||
.into(itemThumbnailView);
|
||||
CoilHelper.INSTANCE.loadThumbnail(itemThumbnailView,
|
||||
item.getStreamEntity().getThumbnailUrl());
|
||||
|
||||
itemView.setOnClickListener(view -> {
|
||||
if (itemBuilder.getOnItemSelectedListener() != null) {
|
||||
|
||||
@@ -8,8 +8,8 @@ import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
|
||||
import org.schabi.newpipe.local.LocalItemBuilder;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
import org.schabi.newpipe.util.image.CoilHelper;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
@@ -29,10 +29,9 @@ public class RemotePlaylistItemHolder extends PlaylistItemHolder {
|
||||
public void updateFromItem(final LocalItem localItem,
|
||||
final HistoryRecordManager historyRecordManager,
|
||||
final DateTimeFormatter dateTimeFormatter) {
|
||||
if (!(localItem instanceof PlaylistRemoteEntity)) {
|
||||
if (!(localItem instanceof PlaylistRemoteEntity item)) {
|
||||
return;
|
||||
}
|
||||
final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem;
|
||||
|
||||
itemTitleView.setText(item.getOrderingName());
|
||||
itemStreamCountView.setText(Localization.localizeStreamCountMini(
|
||||
@@ -45,7 +44,7 @@ public class RemotePlaylistItemHolder extends PlaylistItemHolder {
|
||||
itemUploaderView.setText(ServiceHelper.getNameOfServiceById(item.getServiceId()));
|
||||
}
|
||||
|
||||
PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView);
|
||||
CoilHelper.INSTANCE.loadPlaylistThumbnail(itemThumbnailView, item.getThumbnailUrl());
|
||||
|
||||
super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter);
|
||||
}
|
||||
|
||||
@@ -1,46 +1,63 @@
|
||||
package org.schabi.newpipe.local.subscription;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.os.BundleCompat;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.work.Constraints;
|
||||
import androidx.work.ExistingWorkPolicy;
|
||||
import androidx.work.NetworkType;
|
||||
import androidx.work.OneTimeWorkRequest;
|
||||
import androidx.work.OutOfQuotaPolicy;
|
||||
import androidx.work.WorkManager;
|
||||
|
||||
import com.evernote.android.state.State;
|
||||
import com.livefront.bridge.Bridge;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput;
|
||||
import org.schabi.newpipe.local.subscription.workers.SubscriptionImportWorker;
|
||||
|
||||
public class ImportConfirmationDialog extends DialogFragment {
|
||||
@State
|
||||
protected Intent resultServiceIntent;
|
||||
private static final String INPUT = "input";
|
||||
|
||||
public static void show(@NonNull final Fragment fragment,
|
||||
@NonNull final Intent resultServiceIntent) {
|
||||
final ImportConfirmationDialog confirmationDialog = new ImportConfirmationDialog();
|
||||
confirmationDialog.setResultServiceIntent(resultServiceIntent);
|
||||
public static void show(@NonNull final Fragment fragment, final SubscriptionImportInput input) {
|
||||
final var confirmationDialog = new ImportConfirmationDialog();
|
||||
final var arguments = new Bundle();
|
||||
arguments.putParcelable(INPUT, input);
|
||||
confirmationDialog.setArguments(arguments);
|
||||
confirmationDialog.show(fragment.getParentFragmentManager(), null);
|
||||
}
|
||||
|
||||
public void setResultServiceIntent(final Intent resultServiceIntent) {
|
||||
this.resultServiceIntent = resultServiceIntent;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
|
||||
return new AlertDialog.Builder(requireContext())
|
||||
final var context = requireContext();
|
||||
return new AlertDialog.Builder(context)
|
||||
.setMessage(R.string.import_network_expensive_warning)
|
||||
.setCancelable(true)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setPositiveButton(R.string.ok, (dialogInterface, i) -> {
|
||||
if (resultServiceIntent != null && getContext() != null) {
|
||||
getContext().startService(resultServiceIntent);
|
||||
}
|
||||
final var constraints = new Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build();
|
||||
final var input = BundleCompat.getParcelable(requireArguments(), INPUT,
|
||||
SubscriptionImportInput.class);
|
||||
|
||||
final var req = new OneTimeWorkRequest.Builder(SubscriptionImportWorker.class)
|
||||
.setInputData(input.toData())
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
.setConstraints(constraints)
|
||||
.build();
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(SubscriptionImportWorker.WORK_NAME,
|
||||
ExistingWorkPolicy.APPEND_OR_REPLACE, req);
|
||||
|
||||
dismiss();
|
||||
})
|
||||
.create();
|
||||
@@ -50,10 +67,6 @@ public class ImportConfirmationDialog extends DialogFragment {
|
||||
public void onCreate(@Nullable final Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
if (resultServiceIntent == null) {
|
||||
throw new IllegalStateException("Result intent is null");
|
||||
}
|
||||
|
||||
Bridge.restoreInstanceState(this, savedInstanceState);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.schabi.newpipe.local.subscription
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.LayoutInflater
|
||||
@@ -50,13 +49,11 @@ import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem
|
||||
import org.schabi.newpipe.local.subscription.item.GroupsHeader
|
||||
import org.schabi.newpipe.local.subscription.item.Header
|
||||
import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE
|
||||
import org.schabi.newpipe.local.subscription.workers.SubscriptionExportWorker
|
||||
import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput
|
||||
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper
|
||||
import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable
|
||||
import org.schabi.newpipe.util.NavigationHelper
|
||||
import org.schabi.newpipe.util.OnClickGesture
|
||||
import org.schabi.newpipe.util.ServiceHelper
|
||||
@@ -130,6 +127,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||
// Menu
|
||||
// ////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
|
||||
@@ -223,21 +221,17 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||
}
|
||||
|
||||
private fun requestExportResult(result: ActivityResult) {
|
||||
if (result.data != null && result.resultCode == Activity.RESULT_OK) {
|
||||
activity.startService(
|
||||
Intent(activity, SubscriptionsExportService::class.java)
|
||||
.putExtra(SubscriptionsExportService.KEY_FILE_PATH, result.data?.data)
|
||||
)
|
||||
val data = result.data?.data
|
||||
if (data != null && result.resultCode == Activity.RESULT_OK) {
|
||||
SubscriptionExportWorker.schedule(activity, data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestImportResult(result: ActivityResult) {
|
||||
if (result.data != null && result.resultCode == Activity.RESULT_OK) {
|
||||
val data = result.data?.dataString
|
||||
if (data != null && result.resultCode == Activity.RESULT_OK) {
|
||||
ImportConfirmationDialog.show(
|
||||
this,
|
||||
Intent(activity, SubscriptionsImportService::class.java)
|
||||
.putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE)
|
||||
.putExtra(KEY_VALUE, result.data?.data)
|
||||
this, SubscriptionImportInput.PreviousExportMode(data)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -257,6 +251,8 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||
binding.itemsList.adapter = groupAdapter
|
||||
binding.itemsList.itemAnimator = null
|
||||
|
||||
binding.emptyStateView.setEmptyStateComposable()
|
||||
|
||||
viewModel = ViewModelProvider(this)[SubscriptionViewModel::class.java]
|
||||
viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(this::handleResult) }
|
||||
viewModel.feedGroupsLiveData.observe(viewLifecycleOwner) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.schabi.newpipe.local.subscription
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Pair
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
@@ -48,23 +47,16 @@ class SubscriptionManager(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun upsertAll(infoList: List<Pair<ChannelInfo, List<ChannelTabInfo>>>): List<SubscriptionEntity> {
|
||||
val listEntities = subscriptionTable.upsertAll(
|
||||
infoList.map { SubscriptionEntity.from(it.first) }
|
||||
)
|
||||
fun upsertAll(infoList: List<Pair<ChannelInfo, ChannelTabInfo>>) {
|
||||
val listEntities = infoList.map { SubscriptionEntity.from(it.first) }
|
||||
subscriptionTable.upsertAll(listEntities)
|
||||
|
||||
database.runInTransaction {
|
||||
infoList.forEachIndexed { index, info ->
|
||||
info.second.forEach {
|
||||
feedDatabaseManager.upsertAll(
|
||||
listEntities[index].uid,
|
||||
it.relatedItems.filterIsInstance<StreamInfoItem>()
|
||||
)
|
||||
}
|
||||
val streams = info.second.relatedItems.filterIsInstance<StreamInfoItem>()
|
||||
feedDatabaseManager.upsertAll(listEntities[index].uid, streams)
|
||||
}
|
||||
}
|
||||
|
||||
return listEntities
|
||||
}
|
||||
|
||||
fun updateChannelInfo(info: ChannelInfo): Completable =
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
package org.schabi.newpipe.local.subscription;
|
||||
|
||||
import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL;
|
||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE;
|
||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE;
|
||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE;
|
||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
@@ -37,7 +33,7 @@ import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService;
|
||||
import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput;
|
||||
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard;
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
@@ -168,10 +164,8 @@ public class SubscriptionsImportFragment extends BaseFragment {
|
||||
}
|
||||
|
||||
public void onImportUrl(final String value) {
|
||||
ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class)
|
||||
.putExtra(KEY_MODE, CHANNEL_URL_MODE)
|
||||
.putExtra(KEY_VALUE, value)
|
||||
.putExtra(Constants.KEY_SERVICE_ID, currentServiceId));
|
||||
ImportConfirmationDialog.show(this,
|
||||
new SubscriptionImportInput.ChannelUrlMode(currentServiceId, value));
|
||||
}
|
||||
|
||||
public void onImportFile() {
|
||||
@@ -186,16 +180,10 @@ public class SubscriptionsImportFragment extends BaseFragment {
|
||||
}
|
||||
|
||||
private void requestImportFileResult(final ActivityResult result) {
|
||||
if (result.getData() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.getResultCode() == Activity.RESULT_OK && result.getData().getData() != null) {
|
||||
final String data = result.getData() != null ? result.getData().getDataString() : null;
|
||||
if (result.getResultCode() == Activity.RESULT_OK && data != null) {
|
||||
ImportConfirmationDialog.show(this,
|
||||
new Intent(activity, SubscriptionsImportService.class)
|
||||
.putExtra(KEY_MODE, INPUT_STREAM_MODE)
|
||||
.putExtra(KEY_VALUE, result.getData().getData())
|
||||
.putExtra(Constants.KEY_SERVICE_ID, currentServiceId));
|
||||
new SubscriptionImportInput.InputStreamMode(currentServiceId, data));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
return object : Dialog(requireActivity(), theme) {
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onBackPressed() {
|
||||
if (!this@FeedGroupDialog.onBackPressed()) {
|
||||
super.onBackPressed()
|
||||
|
||||
@@ -9,7 +9,7 @@ import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.OnClickGesture
|
||||
import org.schabi.newpipe.util.image.PicassoHelper
|
||||
import org.schabi.newpipe.util.image.CoilHelper
|
||||
|
||||
class ChannelItem(
|
||||
private val infoItem: ChannelInfoItem,
|
||||
@@ -39,7 +39,7 @@ class ChannelItem(
|
||||
itemChannelDescriptionView.text = infoItem.description
|
||||
}
|
||||
|
||||
PicassoHelper.loadAvatar(infoItem.thumbnails).into(itemThumbnailView)
|
||||
CoilHelper.loadAvatar(itemThumbnailView, infoItem.thumbnails)
|
||||
|
||||
gesturesListener?.run {
|
||||
viewHolder.root.setOnClickListener { selected(infoItem) }
|
||||
|
||||
@@ -3,14 +3,18 @@ package org.schabi.newpipe.local.subscription.item
|
||||
import android.view.View
|
||||
import com.xwray.groupie.viewbinding.BindableItem
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.databinding.ListEmptyViewBinding
|
||||
import org.schabi.newpipe.databinding.ListEmptyViewSubscriptionsBinding
|
||||
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec
|
||||
import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable
|
||||
|
||||
/**
|
||||
* When there are no subscriptions, show a hint to the user about how to import subscriptions
|
||||
*/
|
||||
class ImportSubscriptionsHintPlaceholderItem : BindableItem<ListEmptyViewBinding>() {
|
||||
class ImportSubscriptionsHintPlaceholderItem : BindableItem<ListEmptyViewSubscriptionsBinding>() {
|
||||
override fun getLayout(): Int = R.layout.list_empty_view_subscriptions
|
||||
override fun bind(viewBinding: ListEmptyViewBinding, position: Int) {}
|
||||
override fun bind(viewBinding: ListEmptyViewSubscriptionsBinding, position: Int) {
|
||||
viewBinding.root.setEmptyStateComposable(EmptyStateSpec.NoSubscriptionsHint)
|
||||
}
|
||||
override fun getSpanSize(spanCount: Int, position: Int): Int = spanCount
|
||||
override fun initializeViewBinding(view: View) = ListEmptyViewBinding.bind(view)
|
||||
override fun initializeViewBinding(view: View) = ListEmptyViewSubscriptionsBinding.bind(view)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import org.schabi.newpipe.databinding.PickerSubscriptionItemBinding
|
||||
import org.schabi.newpipe.ktx.AnimationType
|
||||
import org.schabi.newpipe.ktx.animate
|
||||
import org.schabi.newpipe.util.image.PicassoHelper
|
||||
import org.schabi.newpipe.util.image.CoilHelper
|
||||
|
||||
data class PickerSubscriptionItem(
|
||||
val subscriptionEntity: SubscriptionEntity,
|
||||
@@ -21,7 +21,7 @@ data class PickerSubscriptionItem(
|
||||
override fun getSpanSize(spanCount: Int, position: Int): Int = 1
|
||||
|
||||
override fun bind(viewBinding: PickerSubscriptionItemBinding, position: Int) {
|
||||
PicassoHelper.loadAvatar(subscriptionEntity.avatarUrl).into(viewBinding.thumbnailView)
|
||||
CoilHelper.loadAvatar(viewBinding.thumbnailView, subscriptionEntity.avatarUrl)
|
||||
viewBinding.titleView.text = subscriptionEntity.name
|
||||
viewBinding.selectedHighlight.isVisible = isSelected
|
||||
}
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
/*
|
||||
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* BaseImportExportService.java 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.subscription.services;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.text.TextUtils;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.core.app.ServiceCompat;
|
||||
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
|
||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
import io.reactivex.rxjava3.functions.Function;
|
||||
import io.reactivex.rxjava3.processors.PublishProcessor;
|
||||
|
||||
public abstract class BaseImportExportService extends Service {
|
||||
protected final String TAG = this.getClass().getSimpleName();
|
||||
|
||||
protected final CompositeDisposable disposables = new CompositeDisposable();
|
||||
protected final PublishProcessor<String> notificationUpdater = PublishProcessor.create();
|
||||
|
||||
protected NotificationManagerCompat notificationManager;
|
||||
protected NotificationCompat.Builder notificationBuilder;
|
||||
protected SubscriptionManager subscriptionManager;
|
||||
|
||||
private static final int NOTIFICATION_SAMPLING_PERIOD = 2500;
|
||||
|
||||
protected final AtomicInteger currentProgress = new AtomicInteger(-1);
|
||||
protected final AtomicInteger maxProgress = new AtomicInteger(-1);
|
||||
protected final ImportExportEventListener eventListener = new ImportExportEventListener() {
|
||||
@Override
|
||||
public void onSizeReceived(final int size) {
|
||||
maxProgress.set(size);
|
||||
currentProgress.set(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemCompleted(final String itemName) {
|
||||
currentProgress.incrementAndGet();
|
||||
notificationUpdater.onNext(itemName);
|
||||
}
|
||||
};
|
||||
|
||||
protected Toast toast;
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(final Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
subscriptionManager = new SubscriptionManager(this);
|
||||
setupNotification();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
disposeAll();
|
||||
}
|
||||
|
||||
protected void disposeAll() {
|
||||
disposables.clear();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Notification Impl
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
protected abstract int getNotificationId();
|
||||
|
||||
@StringRes
|
||||
public abstract int getTitle();
|
||||
|
||||
protected void setupNotification() {
|
||||
notificationManager = NotificationManagerCompat.from(this);
|
||||
notificationBuilder = createNotification();
|
||||
startForeground(getNotificationId(), notificationBuilder.build());
|
||||
|
||||
final Function<Flowable<String>, Publisher<String>> throttleAfterFirstEmission = flow ->
|
||||
flow.take(1).concatWith(flow.skip(1)
|
||||
.throttleLast(NOTIFICATION_SAMPLING_PERIOD, TimeUnit.MILLISECONDS));
|
||||
|
||||
disposables.add(notificationUpdater
|
||||
.filter(s -> !s.isEmpty())
|
||||
.publish(throttleAfterFirstEmission)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(this::updateNotification));
|
||||
}
|
||||
|
||||
protected void updateNotification(final String text) {
|
||||
notificationBuilder
|
||||
.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1);
|
||||
|
||||
final String progressText = currentProgress + "/" + maxProgress;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
if (!TextUtils.isEmpty(text)) {
|
||||
notificationBuilder.setContentText(text + " (" + progressText + ")");
|
||||
}
|
||||
} else {
|
||||
notificationBuilder.setContentInfo(progressText);
|
||||
notificationBuilder.setContentText(text);
|
||||
}
|
||||
|
||||
notificationManager.notify(getNotificationId(), notificationBuilder.build());
|
||||
}
|
||||
|
||||
protected void stopService() {
|
||||
postErrorResult(null, null);
|
||||
}
|
||||
|
||||
protected void stopAndReportError(final Throwable throwable, final String request) {
|
||||
stopService();
|
||||
ErrorUtil.createNotification(this, new ErrorInfo(
|
||||
throwable, UserAction.SUBSCRIPTION_IMPORT_EXPORT, request));
|
||||
}
|
||||
|
||||
protected void postErrorResult(final String title, final String text) {
|
||||
disposeAll();
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE);
|
||||
stopSelf();
|
||||
|
||||
if (title == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final String textOrEmpty = text == null ? "" : text;
|
||||
notificationBuilder = new NotificationCompat
|
||||
.Builder(this, getString(R.string.notification_channel_id))
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setContentTitle(title)
|
||||
.setStyle(new NotificationCompat.BigTextStyle().bigText(textOrEmpty))
|
||||
.setContentText(textOrEmpty);
|
||||
notificationManager.notify(getNotificationId(), notificationBuilder.build());
|
||||
}
|
||||
|
||||
protected NotificationCompat.Builder createNotification() {
|
||||
return new 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)
|
||||
.setContentTitle(getString(getTitle()));
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Toast
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
protected void showToast(@StringRes final int message) {
|
||||
showToast(getString(message));
|
||||
}
|
||||
|
||||
protected void showToast(final String message) {
|
||||
if (toast != null) {
|
||||
toast.cancel();
|
||||
}
|
||||
|
||||
toast = Toast.makeText(this, message, Toast.LENGTH_SHORT);
|
||||
toast.show();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Error handling
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
protected void handleError(@StringRes final int errorTitle, @NonNull final Throwable error) {
|
||||
String message = getErrorMessage(error);
|
||||
|
||||
if (TextUtils.isEmpty(message)) {
|
||||
final String errorClassName = error.getClass().getName();
|
||||
message = getString(R.string.error_occurred_detail, errorClassName);
|
||||
}
|
||||
|
||||
showToast(errorTitle);
|
||||
postErrorResult(getString(errorTitle), message);
|
||||
}
|
||||
|
||||
protected String getErrorMessage(final Throwable error) {
|
||||
String message = null;
|
||||
if (error instanceof SubscriptionExtractor.InvalidSourceException) {
|
||||
message = getString(R.string.invalid_source);
|
||||
} else if (error instanceof FileNotFoundException) {
|
||||
message = getString(R.string.invalid_file);
|
||||
} else if (ExceptionUtils.isNetworkRelated(error)) {
|
||||
message = getString(R.string.network_error);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package org.schabi.newpipe.local.subscription.services;
|
||||
|
||||
public interface ImportExportEventListener {
|
||||
/**
|
||||
* Called when the size has been resolved.
|
||||
*
|
||||
* @param size how many items there are to import/export
|
||||
*/
|
||||
void onSizeReceived(int size);
|
||||
|
||||
/**
|
||||
* Called every time an item has been parsed/resolved.
|
||||
*
|
||||
* @param itemName the name of the subscription item
|
||||
*/
|
||||
void onItemCompleted(String itemName);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
/*
|
||||
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* ImportExportJsonHelper.java 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.subscription.services;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.grack.nanojson.JsonAppendableWriter;
|
||||
import com.grack.nanojson.JsonArray;
|
||||
import com.grack.nanojson.JsonObject;
|
||||
import com.grack.nanojson.JsonParser;
|
||||
import com.grack.nanojson.JsonWriter;
|
||||
|
||||
import org.schabi.newpipe.BuildConfig;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A JSON implementation capable of importing and exporting subscriptions, it has the advantage
|
||||
* of being able to transfer subscriptions to any device.
|
||||
*/
|
||||
public final class ImportExportJsonHelper {
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Json implementation
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private static final String JSON_APP_VERSION_KEY = "app_version";
|
||||
private static final String JSON_APP_VERSION_INT_KEY = "app_version_int";
|
||||
|
||||
private static final String JSON_SUBSCRIPTIONS_ARRAY_KEY = "subscriptions";
|
||||
|
||||
private static final String JSON_SERVICE_ID_KEY = "service_id";
|
||||
private static final String JSON_URL_KEY = "url";
|
||||
private static final String JSON_NAME_KEY = "name";
|
||||
|
||||
private ImportExportJsonHelper() { }
|
||||
|
||||
/**
|
||||
* Read a JSON source through the input stream.
|
||||
*
|
||||
* @param in the input stream (e.g. a file)
|
||||
* @param eventListener listener for the events generated
|
||||
* @return the parsed subscription items
|
||||
*/
|
||||
public static List<SubscriptionItem> readFrom(
|
||||
final InputStream in, @Nullable final ImportExportEventListener eventListener)
|
||||
throws InvalidSourceException {
|
||||
if (in == null) {
|
||||
throw new InvalidSourceException("input is null");
|
||||
}
|
||||
|
||||
final List<SubscriptionItem> channels = new ArrayList<>();
|
||||
|
||||
try {
|
||||
final JsonObject parentObject = JsonParser.object().from(in);
|
||||
|
||||
if (!parentObject.has(JSON_SUBSCRIPTIONS_ARRAY_KEY)) {
|
||||
throw new InvalidSourceException("Channels array is null");
|
||||
}
|
||||
|
||||
final JsonArray channelsArray = parentObject.getArray(JSON_SUBSCRIPTIONS_ARRAY_KEY);
|
||||
|
||||
if (eventListener != null) {
|
||||
eventListener.onSizeReceived(channelsArray.size());
|
||||
}
|
||||
|
||||
for (final Object o : channelsArray) {
|
||||
if (o instanceof JsonObject) {
|
||||
final JsonObject itemObject = (JsonObject) o;
|
||||
final int serviceId = itemObject.getInt(JSON_SERVICE_ID_KEY, 0);
|
||||
final String url = itemObject.getString(JSON_URL_KEY);
|
||||
final String name = itemObject.getString(JSON_NAME_KEY);
|
||||
|
||||
if (url != null && name != null && !url.isEmpty() && !name.isEmpty()) {
|
||||
channels.add(new SubscriptionItem(serviceId, url, name));
|
||||
if (eventListener != null) {
|
||||
eventListener.onItemCompleted(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (final Throwable e) {
|
||||
throw new InvalidSourceException("Couldn't parse json", e);
|
||||
}
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the subscriptions items list as JSON to the output.
|
||||
*
|
||||
* @param items the list of subscriptions items
|
||||
* @param out the output stream (e.g. a file)
|
||||
* @param eventListener listener for the events generated
|
||||
*/
|
||||
public static void writeTo(final List<SubscriptionItem> items, final OutputStream out,
|
||||
@Nullable final ImportExportEventListener eventListener) {
|
||||
final JsonAppendableWriter writer = JsonWriter.on(out);
|
||||
writeTo(items, writer, eventListener);
|
||||
writer.done();
|
||||
}
|
||||
|
||||
/**
|
||||
* @see #writeTo(List, OutputStream, ImportExportEventListener)
|
||||
* @param items the list of subscriptions items
|
||||
* @param writer the output {@link JsonAppendableWriter}
|
||||
* @param eventListener listener for the events generated
|
||||
*/
|
||||
public static void writeTo(final List<SubscriptionItem> items,
|
||||
final JsonAppendableWriter writer,
|
||||
@Nullable final ImportExportEventListener eventListener) {
|
||||
if (eventListener != null) {
|
||||
eventListener.onSizeReceived(items.size());
|
||||
}
|
||||
|
||||
writer.object();
|
||||
|
||||
writer.value(JSON_APP_VERSION_KEY, BuildConfig.VERSION_NAME);
|
||||
writer.value(JSON_APP_VERSION_INT_KEY, BuildConfig.VERSION_CODE);
|
||||
|
||||
writer.array(JSON_SUBSCRIPTIONS_ARRAY_KEY);
|
||||
for (final SubscriptionItem item : items) {
|
||||
writer.object();
|
||||
writer.value(JSON_SERVICE_ID_KEY, item.getServiceId());
|
||||
writer.value(JSON_URL_KEY, item.getUrl());
|
||||
writer.value(JSON_NAME_KEY, item.getName());
|
||||
writer.end();
|
||||
|
||||
if (eventListener != null) {
|
||||
eventListener.onItemCompleted(item.getName());
|
||||
}
|
||||
}
|
||||
writer.end();
|
||||
|
||||
writer.end();
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
/*
|
||||
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* SubscriptionsExportService.java 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.subscription.services;
|
||||
|
||||
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.content.IntentCompat;
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
|
||||
import org.reactivestreams.Subscriber;
|
||||
import org.reactivestreams.Subscription;
|
||||
import org.schabi.newpipe.App;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||
import org.schabi.newpipe.streams.io.SharpOutputStream;
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.functions.Function;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public class SubscriptionsExportService extends BaseImportExportService {
|
||||
public static final String KEY_FILE_PATH = "key_file_path";
|
||||
|
||||
/**
|
||||
* A {@link LocalBroadcastManager local broadcast} will be made with this action
|
||||
* when the export is successfully completed.
|
||||
*/
|
||||
public static final String EXPORT_COMPLETE_ACTION = App.PACKAGE_NAME + ".local.subscription"
|
||||
+ ".services.SubscriptionsExportService.EXPORT_COMPLETE";
|
||||
|
||||
private Subscription subscription;
|
||||
private StoredFileHelper outFile;
|
||||
private OutputStream outputStream;
|
||||
|
||||
@Override
|
||||
public int onStartCommand(final Intent intent, final int flags, final int startId) {
|
||||
if (intent == null || subscription != null) {
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
final Uri path = IntentCompat.getParcelableExtra(intent, KEY_FILE_PATH, Uri.class);
|
||||
if (path == null) {
|
||||
stopAndReportError(new IllegalStateException(
|
||||
"Exporting to a file, but the path is null"),
|
||||
"Exporting subscriptions");
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
try {
|
||||
outFile = new StoredFileHelper(this, path, "application/json");
|
||||
// truncate the file before writing to it, otherwise if the new content is smaller than
|
||||
// the previous file size, the file will retain part of the previous content and be
|
||||
// corrupted
|
||||
outputStream = new SharpOutputStream(outFile.openAndTruncateStream());
|
||||
} catch (final IOException e) {
|
||||
handleError(e);
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
startExport();
|
||||
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getNotificationId() {
|
||||
return 4567;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTitle() {
|
||||
return R.string.export_ongoing;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void disposeAll() {
|
||||
super.disposeAll();
|
||||
if (subscription != null) {
|
||||
subscription.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private void startExport() {
|
||||
showToast(R.string.export_ongoing);
|
||||
|
||||
subscriptionManager.subscriptionTable().getAll().take(1)
|
||||
.map(subscriptionEntities -> {
|
||||
final List<SubscriptionItem> result =
|
||||
new ArrayList<>(subscriptionEntities.size());
|
||||
for (final SubscriptionEntity entity : subscriptionEntities) {
|
||||
result.add(new SubscriptionItem(entity.getServiceId(), entity.getUrl(),
|
||||
entity.getName()));
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.map(exportToFile())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(getSubscriber());
|
||||
}
|
||||
|
||||
private Subscriber<StoredFileHelper> getSubscriber() {
|
||||
return new Subscriber<StoredFileHelper>() {
|
||||
@Override
|
||||
public void onSubscribe(final Subscription s) {
|
||||
subscription = s;
|
||||
s.request(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(final StoredFileHelper file) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "startExport() success: file = " + file);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(final Throwable error) {
|
||||
Log.e(TAG, "onError() called with: error = [" + error + "]", error);
|
||||
handleError(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
LocalBroadcastManager.getInstance(SubscriptionsExportService.this)
|
||||
.sendBroadcast(new Intent(EXPORT_COMPLETE_ACTION));
|
||||
showToast(R.string.export_complete_toast);
|
||||
stopService();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Function<List<SubscriptionItem>, StoredFileHelper> exportToFile() {
|
||||
return subscriptionItems -> {
|
||||
ImportExportJsonHelper.writeTo(subscriptionItems, outputStream, eventListener);
|
||||
return outFile;
|
||||
};
|
||||
}
|
||||
|
||||
protected void handleError(final Throwable error) {
|
||||
super.handleError(R.string.subscriptions_export_unsuccessful, error);
|
||||
}
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
/*
|
||||
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* SubscriptionsImportService.java 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.subscription.services;
|
||||
|
||||
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
import static org.schabi.newpipe.streams.io.StoredFileHelper.DEFAULT_MIME;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.util.Pair;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.IntentCompat;
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
|
||||
import org.reactivestreams.Subscriber;
|
||||
import org.reactivestreams.Subscription;
|
||||
import org.schabi.newpipe.App;
|
||||
import org.schabi.newpipe.R;
|
||||
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.channel.tabs.ChannelTabInfo;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||
import org.schabi.newpipe.streams.io.SharpInputStream;
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
import io.reactivex.rxjava3.core.Notification;
|
||||
import io.reactivex.rxjava3.functions.Consumer;
|
||||
import io.reactivex.rxjava3.functions.Function;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public class SubscriptionsImportService extends BaseImportExportService {
|
||||
public static final int CHANNEL_URL_MODE = 0;
|
||||
public static final int INPUT_STREAM_MODE = 1;
|
||||
public static final int PREVIOUS_EXPORT_MODE = 2;
|
||||
public static final String KEY_MODE = "key_mode";
|
||||
public static final String KEY_VALUE = "key_value";
|
||||
|
||||
/**
|
||||
* A {@link LocalBroadcastManager local broadcast} will be made with this action
|
||||
* when the import is successfully completed.
|
||||
*/
|
||||
public static final String IMPORT_COMPLETE_ACTION = App.PACKAGE_NAME + ".local.subscription"
|
||||
+ ".services.SubscriptionsImportService.IMPORT_COMPLETE";
|
||||
|
||||
/**
|
||||
* How many extractions running in parallel.
|
||||
*/
|
||||
public static final int PARALLEL_EXTRACTIONS = 8;
|
||||
|
||||
/**
|
||||
* Number of items to buffer to mass-insert in the subscriptions table,
|
||||
* this leads to a better performance as we can then use db transactions.
|
||||
*/
|
||||
public static final int BUFFER_COUNT_BEFORE_INSERT = 50;
|
||||
|
||||
private Subscription subscription;
|
||||
private int currentMode;
|
||||
private int currentServiceId;
|
||||
@Nullable
|
||||
private String channelUrl;
|
||||
@Nullable
|
||||
private InputStream inputStream;
|
||||
@Nullable
|
||||
private String inputStreamType;
|
||||
|
||||
@Override
|
||||
public int onStartCommand(final Intent intent, final int flags, final int startId) {
|
||||
if (intent == null || subscription != null) {
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
currentMode = intent.getIntExtra(KEY_MODE, -1);
|
||||
currentServiceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, Constants.NO_SERVICE_ID);
|
||||
|
||||
if (currentMode == CHANNEL_URL_MODE) {
|
||||
channelUrl = intent.getStringExtra(KEY_VALUE);
|
||||
} else {
|
||||
final Uri uri = IntentCompat.getParcelableExtra(intent, KEY_VALUE, Uri.class);
|
||||
if (uri == null) {
|
||||
stopAndReportError(new IllegalStateException(
|
||||
"Importing from input stream, but file path is null"),
|
||||
"Importing subscriptions");
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
try {
|
||||
final StoredFileHelper fileHelper = new StoredFileHelper(this, uri, DEFAULT_MIME);
|
||||
inputStream = new SharpInputStream(fileHelper.getStream());
|
||||
inputStreamType = fileHelper.getType();
|
||||
|
||||
if (inputStreamType == null || inputStreamType.equals(DEFAULT_MIME)) {
|
||||
// mime type could not be determined, just take file extension
|
||||
final String name = fileHelper.getName();
|
||||
final int pointIndex = name.lastIndexOf('.');
|
||||
if (pointIndex == -1 || pointIndex >= name.length() - 1) {
|
||||
inputStreamType = DEFAULT_MIME; // no extension, will fail in the extractor
|
||||
} else {
|
||||
inputStreamType = name.substring(pointIndex + 1);
|
||||
}
|
||||
}
|
||||
} catch (final IOException e) {
|
||||
handleError(e);
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentMode == -1 || currentMode == CHANNEL_URL_MODE && channelUrl == null) {
|
||||
final String errorDescription = "Some important field is null or in illegal state: "
|
||||
+ "currentMode=[" + currentMode + "], "
|
||||
+ "channelUrl=[" + channelUrl + "], "
|
||||
+ "inputStream=[" + inputStream + "]";
|
||||
stopAndReportError(new IllegalStateException(errorDescription),
|
||||
"Importing subscriptions");
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
startImport();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getNotificationId() {
|
||||
return 4568;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTitle() {
|
||||
return R.string.import_ongoing;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void disposeAll() {
|
||||
super.disposeAll();
|
||||
if (subscription != null) {
|
||||
subscription.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Imports
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private void startImport() {
|
||||
showToast(R.string.import_ongoing);
|
||||
|
||||
Flowable<List<SubscriptionItem>> flowable = null;
|
||||
switch (currentMode) {
|
||||
case CHANNEL_URL_MODE:
|
||||
flowable = importFromChannelUrl();
|
||||
break;
|
||||
case INPUT_STREAM_MODE:
|
||||
flowable = importFromInputStream();
|
||||
break;
|
||||
case PREVIOUS_EXPORT_MODE:
|
||||
flowable = importFromPreviousExport();
|
||||
break;
|
||||
}
|
||||
|
||||
if (flowable == null) {
|
||||
final String message = "Flowable given by \"importFrom\" is null "
|
||||
+ "(current mode: " + currentMode + ")";
|
||||
stopAndReportError(new IllegalStateException(message), "Importing subscriptions");
|
||||
return;
|
||||
}
|
||||
|
||||
flowable.doOnNext(subscriptionItems ->
|
||||
eventListener.onSizeReceived(subscriptionItems.size()))
|
||||
.flatMap(Flowable::fromIterable)
|
||||
|
||||
.parallel(PARALLEL_EXTRACTIONS)
|
||||
.runOn(Schedulers.io())
|
||||
.map((Function<SubscriptionItem, Notification<Pair<ChannelInfo,
|
||||
List<ChannelTabInfo>>>>) subscriptionItem -> {
|
||||
try {
|
||||
final ChannelInfo channelInfo = ExtractorHelper
|
||||
.getChannelInfo(subscriptionItem.getServiceId(),
|
||||
subscriptionItem.getUrl(), true)
|
||||
.blockingGet();
|
||||
return Notification.createOnNext(new Pair<>(channelInfo,
|
||||
Collections.singletonList(
|
||||
ExtractorHelper.getChannelTab(
|
||||
subscriptionItem.getServiceId(),
|
||||
channelInfo.getTabs().get(0), true).blockingGet()
|
||||
)));
|
||||
} catch (final Throwable e) {
|
||||
return Notification.createOnError(e);
|
||||
}
|
||||
})
|
||||
.sequential()
|
||||
|
||||
.observeOn(Schedulers.io())
|
||||
.doOnNext(getNotificationsConsumer())
|
||||
|
||||
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
||||
.map(upsertBatch())
|
||||
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(getSubscriber());
|
||||
}
|
||||
|
||||
private Subscriber<List<SubscriptionEntity>> getSubscriber() {
|
||||
return new Subscriber<>() {
|
||||
@Override
|
||||
public void onSubscribe(final Subscription s) {
|
||||
subscription = s;
|
||||
s.request(Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(final List<SubscriptionEntity> successfulInserted) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "startImport() " + successfulInserted.size()
|
||||
+ " items successfully inserted into the database");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(final Throwable error) {
|
||||
Log.e(TAG, "Got an error!", error);
|
||||
handleError(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
LocalBroadcastManager.getInstance(SubscriptionsImportService.this)
|
||||
.sendBroadcast(new Intent(IMPORT_COMPLETE_ACTION));
|
||||
showToast(R.string.import_complete_toast);
|
||||
stopService();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Consumer<Notification<Pair<ChannelInfo,
|
||||
List<ChannelTabInfo>>>> getNotificationsConsumer() {
|
||||
return notification -> {
|
||||
if (notification.isOnNext()) {
|
||||
final String name = notification.getValue().first.getName();
|
||||
eventListener.onItemCompleted(!TextUtils.isEmpty(name) ? name : "");
|
||||
} else if (notification.isOnError()) {
|
||||
final Throwable error = notification.getError();
|
||||
final Throwable cause = error.getCause();
|
||||
if (error instanceof IOException) {
|
||||
throw error;
|
||||
} else if (cause instanceof IOException) {
|
||||
throw cause;
|
||||
} else if (ExceptionUtils.isNetworkRelated(error)) {
|
||||
throw new IOException(error);
|
||||
}
|
||||
|
||||
eventListener.onItemCompleted("");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Function<List<Notification<Pair<ChannelInfo, List<ChannelTabInfo>>>>,
|
||||
List<SubscriptionEntity>> upsertBatch() {
|
||||
return notificationList -> {
|
||||
final List<Pair<ChannelInfo, List<ChannelTabInfo>>> infoList =
|
||||
new ArrayList<>(notificationList.size());
|
||||
for (final Notification<Pair<ChannelInfo, List<ChannelTabInfo>>> n : notificationList) {
|
||||
if (n.isOnNext()) {
|
||||
infoList.add(n.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
return subscriptionManager.upsertAll(infoList);
|
||||
};
|
||||
}
|
||||
|
||||
private Flowable<List<SubscriptionItem>> importFromChannelUrl() {
|
||||
return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId)
|
||||
.getSubscriptionExtractor()
|
||||
.fromChannelUrl(channelUrl));
|
||||
}
|
||||
|
||||
private Flowable<List<SubscriptionItem>> importFromInputStream() {
|
||||
Objects.requireNonNull(inputStream);
|
||||
Objects.requireNonNull(inputStreamType);
|
||||
|
||||
return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId)
|
||||
.getSubscriptionExtractor()
|
||||
.fromInputStream(inputStream, inputStreamType));
|
||||
}
|
||||
|
||||
private Flowable<List<SubscriptionItem>> importFromPreviousExport() {
|
||||
return Flowable.fromCallable(() -> ImportExportJsonHelper.readFrom(inputStream, null));
|
||||
}
|
||||
|
||||
protected void handleError(@NonNull final Throwable error) {
|
||||
super.handleError(R.string.subscriptions_import_unsuccessful, error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* ImportExportJsonHelper.java 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.subscription.workers
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import kotlinx.serialization.json.encodeToStream
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
/**
|
||||
* A JSON implementation capable of importing and exporting subscriptions, it has the advantage
|
||||
* of being able to transfer subscriptions to any device.
|
||||
*/
|
||||
object ImportExportJsonHelper {
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
/**
|
||||
* Read a JSON source through the input stream.
|
||||
*
|
||||
* @param in the input stream (e.g. a file)
|
||||
* @return the parsed subscription items
|
||||
*/
|
||||
@JvmStatic
|
||||
@Throws(InvalidSourceException::class)
|
||||
fun readFrom(`in`: InputStream?): List<SubscriptionItem> {
|
||||
if (`in` == null) {
|
||||
throw InvalidSourceException("input is null")
|
||||
}
|
||||
|
||||
try {
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
return json.decodeFromStream<SubscriptionData>(`in`).subscriptions
|
||||
} catch (e: Throwable) {
|
||||
throw InvalidSourceException("Couldn't parse json", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the subscriptions items list as JSON to the output.
|
||||
*
|
||||
* @param items the list of subscriptions items
|
||||
* @param out the output stream (e.g. a file)
|
||||
*/
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@JvmStatic
|
||||
fun writeTo(
|
||||
items: List<SubscriptionItem>,
|
||||
out: OutputStream,
|
||||
) {
|
||||
json.encodeToStream(SubscriptionData(items), out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.schabi.newpipe.local.subscription.workers
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
|
||||
@Serializable
|
||||
class SubscriptionData(
|
||||
val subscriptions: List<SubscriptionItem>
|
||||
) {
|
||||
@SerialName("app_version")
|
||||
private val appVersion = BuildConfig.VERSION_NAME
|
||||
|
||||
@SerialName("app_version_int")
|
||||
private val appVersionInt = BuildConfig.VERSION_CODE
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SubscriptionItem(
|
||||
@SerialName("service_id")
|
||||
val serviceId: Int,
|
||||
val url: String,
|
||||
val name: String
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
package org.schabi.newpipe.local.subscription.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.reactive.awaitFirst
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.R
|
||||
|
||||
class SubscriptionExportWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
// This is needed for API levels < 31 (Android S).
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
return createForegroundInfo(applicationContext.getString(R.string.export_ongoing))
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
return try {
|
||||
val uri = inputData.getString(EXPORT_PATH)!!.toUri()
|
||||
val table = NewPipeDatabase.getInstance(applicationContext).subscriptionDAO()
|
||||
val subscriptions =
|
||||
table.getAll()
|
||||
.awaitFirst()
|
||||
.map { SubscriptionItem(it.serviceId, it.url ?: "", it.name ?: "") }
|
||||
|
||||
val qty = subscriptions.size
|
||||
val title = applicationContext.resources.getQuantityString(R.plurals.export_subscriptions, qty, qty)
|
||||
setForeground(createForegroundInfo(title))
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
// Truncate file if it already exists
|
||||
applicationContext.contentResolver.openOutputStream(uri, "wt")?.use {
|
||||
ImportExportJsonHelper.writeTo(subscriptions, it)
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.i(TAG, "Exported $qty subscriptions")
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast
|
||||
.makeText(applicationContext, R.string.export_complete_toast, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.e(TAG, "Error while exporting subscriptions", e)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast
|
||||
.makeText(applicationContext, R.string.subscriptions_export_unsuccessful, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
|
||||
return Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createForegroundInfo(title: String): ForegroundInfo {
|
||||
val notification =
|
||||
NotificationCompat
|
||||
.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setOngoing(true)
|
||||
.setProgress(-1, -1, true)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.setContentTitle(title)
|
||||
.build()
|
||||
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0
|
||||
return ForegroundInfo(NOTIFICATION_ID, notification, serviceType)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SubscriptionExportWork"
|
||||
private const val NOTIFICATION_ID = 4567
|
||||
private const val NOTIFICATION_CHANNEL_ID = "newpipe"
|
||||
private const val WORK_NAME = "exportSubscriptions"
|
||||
private const val EXPORT_PATH = "exportPath"
|
||||
|
||||
fun schedule(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
) {
|
||||
val data = workDataOf(EXPORT_PATH to uri.toString())
|
||||
val workRequest =
|
||||
OneTimeWorkRequestBuilder<SubscriptionExportWorker>()
|
||||
.setInputData(data)
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
.build()
|
||||
|
||||
WorkManager
|
||||
.getInstance(context)
|
||||
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package org.schabi.newpipe.local.subscription.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
|
||||
class SubscriptionImportWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
// This is needed for API levels < 31 (Android S).
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
return createForegroundInfo(applicationContext.getString(R.string.import_ongoing), null, 0, 0)
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val subscriptions =
|
||||
try {
|
||||
loadSubscriptionsFromInput(SubscriptionImportInput.fromData(inputData))
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.e(TAG, "Error while loading subscriptions from path", e)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast
|
||||
.makeText(applicationContext, R.string.subscriptions_import_unsuccessful, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val mutex = Mutex()
|
||||
var index = 1
|
||||
val qty = subscriptions.size
|
||||
var title =
|
||||
applicationContext.resources.getQuantityString(R.plurals.load_subscriptions, qty, qty)
|
||||
|
||||
val channelInfoList =
|
||||
try {
|
||||
withContext(Dispatchers.IO.limitedParallelism(PARALLEL_EXTRACTIONS)) {
|
||||
subscriptions
|
||||
.map {
|
||||
async {
|
||||
val channelInfo =
|
||||
ExtractorHelper.getChannelInfo(it.serviceId, it.url, true).await()
|
||||
val channelTab =
|
||||
ExtractorHelper.getChannelTab(it.serviceId, channelInfo.tabs[0], true).await()
|
||||
|
||||
val currentIndex = mutex.withLock { index++ }
|
||||
setForeground(createForegroundInfo(title, channelInfo.name, currentIndex, qty))
|
||||
|
||||
channelInfo to channelTab
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.e(TAG, "Error while loading subscription data", e)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(applicationContext, R.string.subscriptions_import_unsuccessful, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
title = applicationContext.resources.getQuantityString(R.plurals.import_subscriptions, qty, qty)
|
||||
setForeground(createForegroundInfo(title, null, 0, 0))
|
||||
index = 0
|
||||
|
||||
val subscriptionManager = SubscriptionManager(applicationContext)
|
||||
for (chunk in channelInfoList.chunked(BUFFER_COUNT_BEFORE_INSERT)) {
|
||||
withContext(Dispatchers.IO) {
|
||||
subscriptionManager.upsertAll(chunk)
|
||||
}
|
||||
index += chunk.size
|
||||
setForeground(createForegroundInfo(title, null, index, qty))
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(applicationContext, R.string.import_complete_toast, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private suspend fun loadSubscriptionsFromInput(input: SubscriptionImportInput): List<SubscriptionItem> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
when (input) {
|
||||
is SubscriptionImportInput.ChannelUrlMode ->
|
||||
NewPipe.getService(input.serviceId).subscriptionExtractor
|
||||
.fromChannelUrl(input.url)
|
||||
.map { SubscriptionItem(it.serviceId, it.url, it.name) }
|
||||
|
||||
is SubscriptionImportInput.InputStreamMode ->
|
||||
applicationContext.contentResolver.openInputStream(input.url.toUri())?.use {
|
||||
val contentType =
|
||||
MimeTypeMap.getFileExtensionFromUrl(input.url).ifEmpty { DEFAULT_MIME }
|
||||
NewPipe.getService(input.serviceId).subscriptionExtractor
|
||||
.fromInputStream(it, contentType)
|
||||
.map { SubscriptionItem(it.serviceId, it.url, it.name) }
|
||||
}
|
||||
|
||||
is SubscriptionImportInput.PreviousExportMode ->
|
||||
applicationContext.contentResolver.openInputStream(input.url.toUri())?.use {
|
||||
ImportExportJsonHelper.readFrom(it)
|
||||
}
|
||||
} ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createForegroundInfo(
|
||||
title: String,
|
||||
text: String?,
|
||||
currentProgress: Int,
|
||||
maxProgress: Int,
|
||||
): ForegroundInfo {
|
||||
val notification =
|
||||
NotificationCompat
|
||||
.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setOngoing(true)
|
||||
.setProgress(maxProgress, currentProgress, currentProgress == 0)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.addAction(
|
||||
R.drawable.ic_close,
|
||||
applicationContext.getString(R.string.cancel),
|
||||
WorkManager.getInstance(applicationContext).createCancelPendingIntent(id),
|
||||
).apply {
|
||||
if (currentProgress > 0 && maxProgress > 0) {
|
||||
val progressText = "$currentProgress/$maxProgress"
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
setSubText(progressText)
|
||||
} else {
|
||||
setContentInfo(progressText)
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0
|
||||
|
||||
return ForegroundInfo(NOTIFICATION_ID, notification, serviceType)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Log tag length is limited to 23 characters on API levels < 24.
|
||||
private const val TAG = "SubscriptionImport"
|
||||
|
||||
private const val NOTIFICATION_ID = 4568
|
||||
private const val NOTIFICATION_CHANNEL_ID = "newpipe"
|
||||
private const val DEFAULT_MIME = "application/octet-stream"
|
||||
private const val PARALLEL_EXTRACTIONS = 8
|
||||
private const val BUFFER_COUNT_BEFORE_INSERT = 50
|
||||
|
||||
const val WORK_NAME = "SubscriptionImportWorker"
|
||||
}
|
||||
}
|
||||
|
||||
sealed class SubscriptionImportInput : Parcelable {
|
||||
@Parcelize
|
||||
data class ChannelUrlMode(val serviceId: Int, val url: String) : SubscriptionImportInput()
|
||||
@Parcelize
|
||||
data class InputStreamMode(val serviceId: Int, val url: String) : SubscriptionImportInput()
|
||||
@Parcelize
|
||||
data class PreviousExportMode(val url: String) : SubscriptionImportInput()
|
||||
|
||||
fun toData(): Data {
|
||||
val (mode, serviceId, url) = when (this) {
|
||||
is ChannelUrlMode -> Triple(CHANNEL_URL_MODE, serviceId, url)
|
||||
is InputStreamMode -> Triple(INPUT_STREAM_MODE, serviceId, url)
|
||||
is PreviousExportMode -> Triple(PREVIOUS_EXPORT_MODE, null, url)
|
||||
}
|
||||
return workDataOf("mode" to mode, "service_id" to serviceId, "url" to url)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val CHANNEL_URL_MODE = 0
|
||||
private const val INPUT_STREAM_MODE = 1
|
||||
private const val PREVIOUS_EXPORT_MODE = 2
|
||||
|
||||
fun fromData(data: Data): SubscriptionImportInput {
|
||||
val mode = data.getInt("mode", PREVIOUS_EXPORT_MODE)
|
||||
when (mode) {
|
||||
CHANNEL_URL_MODE -> {
|
||||
val serviceId = data.getInt("service_id", -1)
|
||||
if (serviceId == -1) {
|
||||
throw IllegalArgumentException("No service id provided")
|
||||
}
|
||||
val url = data.getString("url")!!
|
||||
return ChannelUrlMode(serviceId, url)
|
||||
}
|
||||
INPUT_STREAM_MODE -> {
|
||||
val serviceId = data.getInt("service_id", -1)
|
||||
if (serviceId == -1) {
|
||||
throw IllegalArgumentException("No service id provided")
|
||||
}
|
||||
val url = data.getString("url")!!
|
||||
return InputStreamMode(serviceId, url)
|
||||
}
|
||||
PREVIOUS_EXPORT_MODE -> {
|
||||
val url = data.getString("url")!!
|
||||
return PreviousExportMode(url)
|
||||
}
|
||||
else -> throw IllegalArgumentException("Unknown mode: $mode")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.schabi.newpipe.paging
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
import org.schabi.newpipe.extractor.Page
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfo
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
|
||||
|
||||
class CommentRepliesSource(
|
||||
private val commentInfo: CommentsInfoItem,
|
||||
) : PagingSource<Page, CommentsInfoItem>() {
|
||||
private val service = NewPipe.getService(commentInfo.serviceId)
|
||||
|
||||
override suspend fun load(params: LoadParams<Page>): LoadResult<Page, CommentsInfoItem> {
|
||||
// params.key is null the first time load() is called, and we need to return the first page
|
||||
val repliesPage = params.key ?: commentInfo.replies
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
CommentsInfo.getMoreItems(service, commentInfo.url, repliesPage)
|
||||
}
|
||||
return LoadResult.Page(info.items, null, info.nextPage)
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<Page, CommentsInfoItem>) = null
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.schabi.newpipe.paging
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
import org.schabi.newpipe.extractor.Page
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfo
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
|
||||
import org.schabi.newpipe.ui.components.video.comment.CommentInfo
|
||||
|
||||
class CommentsSource(private val commentInfo: CommentInfo) : PagingSource<Page, CommentsInfoItem>() {
|
||||
private val service = NewPipe.getService(commentInfo.serviceId)
|
||||
|
||||
override suspend fun load(params: LoadParams<Page>): LoadResult<Page, CommentsInfoItem> {
|
||||
// params.key is null the first time the load() function is called, so we need to return the
|
||||
// first batch of already-loaded comments
|
||||
|
||||
if (params.key == null) {
|
||||
return LoadResult.Page(commentInfo.comments, null, commentInfo.nextPage)
|
||||
} else {
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
CommentsInfo.getMoreItems(service, commentInfo.url, params.key)
|
||||
}
|
||||
|
||||
return LoadResult.Page(info.items, null, info.nextPage)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<Page, CommentsInfoItem>) = null
|
||||
}
|
||||
@@ -95,8 +95,48 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||
getSupportActionBar().setTitle(R.string.title_activity_play_queue);
|
||||
}
|
||||
|
||||
serviceConnection = getServiceConnection();
|
||||
bind();
|
||||
serviceConnection = new ServiceConnection() {
|
||||
@Override
|
||||
public void onServiceDisconnected(final ComponentName name) {
|
||||
Log.d(TAG, "Player service is disconnected");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceConnected(final ComponentName name, final IBinder binder) {
|
||||
Log.d(TAG, "Player service is connected");
|
||||
|
||||
if (binder instanceof PlayerService.LocalBinder) {
|
||||
@Nullable final PlayerService s =
|
||||
((PlayerService.LocalBinder) binder).getService();
|
||||
if (s == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"PlayerService.LocalBinder.getService() must never be"
|
||||
+ "null after the service connects");
|
||||
}
|
||||
player = s.getPlayer();
|
||||
}
|
||||
|
||||
if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {
|
||||
unbind();
|
||||
} else {
|
||||
onQueueUpdate(player.getPlayQueue());
|
||||
buildComponents();
|
||||
if (player != null) {
|
||||
player.setActivityListener(PlayQueueActivity.this);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Note: this code should not really exist, and PlayerHolder should be used instead, but
|
||||
// it will be rewritten when NewPlayer will replace the current player.
|
||||
final Intent bindIntent = new Intent(this, PlayerService.class);
|
||||
bindIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
|
||||
final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE);
|
||||
if (!success) {
|
||||
unbindService(serviceConnection);
|
||||
}
|
||||
serviceBound = success;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -178,19 +218,6 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Service Connection
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private void bind() {
|
||||
// Note: this code should not really exist, and PlayerHolder should be used instead, but
|
||||
// it will be rewritten when NewPlayer will replace the current player.
|
||||
final Intent bindIntent = new Intent(this, PlayerService.class);
|
||||
bindIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
|
||||
final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE);
|
||||
if (!success) {
|
||||
unbindService(serviceConnection);
|
||||
}
|
||||
serviceBound = success;
|
||||
}
|
||||
|
||||
private void unbind() {
|
||||
if (serviceBound) {
|
||||
@@ -210,34 +237,6 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||
}
|
||||
}
|
||||
|
||||
private ServiceConnection getServiceConnection() {
|
||||
return new ServiceConnection() {
|
||||
@Override
|
||||
public void onServiceDisconnected(final ComponentName name) {
|
||||
Log.d(TAG, "Player service is disconnected");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceConnected(final ComponentName name, final IBinder service) {
|
||||
Log.d(TAG, "Player service is connected");
|
||||
|
||||
if (service instanceof PlayerService.LocalBinder) {
|
||||
player = ((PlayerService.LocalBinder) service).getService().getPlayer();
|
||||
}
|
||||
|
||||
if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {
|
||||
unbind();
|
||||
} else {
|
||||
onQueueUpdate(player.getPlayQueue());
|
||||
buildComponents();
|
||||
if (player != null) {
|
||||
player.setActivityListener(PlayQueueActivity.this);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Component Building
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -45,6 +45,7 @@ import static org.schabi.newpipe.player.notification.NotificationConstants.ACTIO
|
||||
import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex;
|
||||
import static org.schabi.newpipe.util.ListHelper.getResolutionIndex;
|
||||
import static java.util.concurrent.TimeUnit.MILLISECONDS;
|
||||
import static coil3.Image_androidKt.toBitmap;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
@@ -52,7 +53,6 @@ import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.media.AudioManager;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
import android.util.Log;
|
||||
@@ -80,8 +80,6 @@ import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
|
||||
import com.google.android.exoplayer2.trackselection.MappingTrackSelector;
|
||||
import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter;
|
||||
import com.google.android.exoplayer2.video.VideoSize;
|
||||
import com.squareup.picasso.Picasso;
|
||||
import com.squareup.picasso.Target;
|
||||
|
||||
import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.R;
|
||||
@@ -125,13 +123,14 @@ import org.schabi.newpipe.util.ListHelper;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.SerializedCache;
|
||||
import org.schabi.newpipe.util.StreamTypeUtil;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.image.CoilHelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import coil3.target.Target;
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Observable;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
@@ -140,6 +139,10 @@ import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.disposables.SerialDisposable;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
/**
|
||||
* The ExoPlayer wrapper & Player business logic.
|
||||
* Only instantiated once, from {@link PlayerService}.
|
||||
*/
|
||||
public final class Player implements PlaybackListener, Listener {
|
||||
public static final boolean DEBUG = MainActivity.DEBUG;
|
||||
public static final String TAG = Player.class.getSimpleName();
|
||||
@@ -180,7 +183,6 @@ public final class Player implements PlaybackListener, Listener {
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
public static final int RENDERER_UNAVAILABLE = -1;
|
||||
private static final String PICASSO_PLAYER_THUMBNAIL_TAG = "PICASSO_PLAYER_THUMBNAIL_TAG";
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Playback
|
||||
@@ -199,6 +201,8 @@ public final class Player implements PlaybackListener, Listener {
|
||||
private MediaItemTag currentMetadata;
|
||||
@Nullable
|
||||
private Bitmap currentThumbnail;
|
||||
@Nullable
|
||||
private coil3.request.Disposable thumbnailDisposable;
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Player
|
||||
@@ -254,12 +258,6 @@ public final class Player implements PlaybackListener, Listener {
|
||||
@NonNull
|
||||
private final CompositeDisposable streamItemDisposable = new CompositeDisposable();
|
||||
|
||||
// This is the only listener we need for thumbnail loading, since there is always at most only
|
||||
// one thumbnail being loaded at a time. This field is also here to maintain a strong reference,
|
||||
// which would otherwise be garbage collected since Picasso holds weak references to targets.
|
||||
@NonNull
|
||||
private final Target currentThumbnailTarget;
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Utils
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@@ -312,8 +310,6 @@ public final class Player implements PlaybackListener, Listener {
|
||||
videoResolver = new VideoPlaybackResolver(context, dataSource, getQualityResolver());
|
||||
audioResolver = new AudioPlaybackResolver(context, dataSource);
|
||||
|
||||
currentThumbnailTarget = getCurrentThumbnailTarget();
|
||||
|
||||
// The UIs added here should always be present. They will be initialized when the player
|
||||
// reaches the initialization step. Make sure the media session ui is before the
|
||||
// notification ui in the UIs list, since the notification depends on the media session in
|
||||
@@ -413,12 +409,11 @@ public final class Player implements PlaybackListener, Listener {
|
||||
.subscribe(info -> {
|
||||
final @Nullable PlayQueue oldPlayQueue = playQueue;
|
||||
info.setStartPosition(data.getSeconds());
|
||||
final PlayQueueItem playQueueItem = new PlayQueueItem(info);
|
||||
final PlayQueueItem item = new PlayQueueItem(info);
|
||||
|
||||
// If the stream is already playing,
|
||||
// we can just seek to the appropriate timestamp
|
||||
if (oldPlayQueue != null
|
||||
&& playQueueItem.isSameItem(oldPlayQueue.getItem())) {
|
||||
if (oldPlayQueue != null && item.equals(oldPlayQueue.getItem())) {
|
||||
// Player can have state = IDLE when playback is stopped or failed
|
||||
// and we should retry in this case
|
||||
if (simpleExoPlayer.getPlaybackState()
|
||||
@@ -434,12 +429,12 @@ public final class Player implements PlaybackListener, Listener {
|
||||
|
||||
// If there is no queue yet, just add our item
|
||||
if (oldPlayQueue == null) {
|
||||
newPlayQueue = new SinglePlayQueue(playQueueItem);
|
||||
newPlayQueue = new SinglePlayQueue(item);
|
||||
|
||||
// else we add the timestamped stream behind the current video
|
||||
// and start playing it.
|
||||
} else {
|
||||
oldPlayQueue.enqueueNext(playQueueItem, true);
|
||||
oldPlayQueue.enqueueNext(item, true);
|
||||
oldPlayQueue.offsetIndex(1);
|
||||
newPlayQueue = oldPlayQueue;
|
||||
}
|
||||
@@ -482,8 +477,8 @@ public final class Player implements PlaybackListener, Listener {
|
||||
if (!exoPlayerIsNull()
|
||||
&& newQueue.size() == 1 && newQueue.getItem() != null
|
||||
&& playQueue != null && playQueue.size() == 1 && playQueue.getItem() != null
|
||||
&& newQueue.getItem().isSameItem(playQueue.getItem())
|
||||
&& newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) {
|
||||
&& newQueue.getItem().equals(playQueue.getItem())
|
||||
&& newQueue.getItem().getRecoveryPosition() != Long.MIN_VALUE) {
|
||||
// Player can have state = IDLE when playback is stopped or failed
|
||||
// and we should retry in this case
|
||||
if (simpleExoPlayer.getPlaybackState()
|
||||
@@ -512,7 +507,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
&& (playQueue == null || !playQueue.equalStreamsAndIndex(newQueue))
|
||||
&& !newQueue.isEmpty()
|
||||
&& newQueue.getItem() != null
|
||||
&& newQueue.getItem().getRecoveryPosition() == PlayQueueItem.RECOVERY_UNSET) {
|
||||
&& newQueue.getItem().getRecoveryPosition() == Long.MIN_VALUE) {
|
||||
databaseUpdateDisposable.add(recordManager.loadStreamState(newQueue.getItem())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
// Do not place initPlayback() in doFinally() because
|
||||
@@ -573,33 +568,35 @@ public final class Player implements PlaybackListener, Listener {
|
||||
}
|
||||
|
||||
private void initUIsForCurrentPlayerType() {
|
||||
if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN)
|
||||
|| (UIs.get(PopupPlayerUi.class).isPresent() && playerType == PlayerType.POPUP)) {
|
||||
if ((UIs.get(MainPlayerUi.class) != null && playerType == PlayerType.MAIN)
|
||||
|| (UIs.get(PopupPlayerUi.class) != null
|
||||
&& playerType == PlayerType.POPUP)) {
|
||||
// correct UI already in place
|
||||
return;
|
||||
}
|
||||
|
||||
// try to reuse binding if possible
|
||||
final PlayerBinding binding = UIs.get(VideoPlayerUi.class).map(VideoPlayerUi::getBinding)
|
||||
.orElseGet(() -> {
|
||||
if (playerType == PlayerType.AUDIO) {
|
||||
return null;
|
||||
} else {
|
||||
return PlayerBinding.inflate(LayoutInflater.from(context));
|
||||
}
|
||||
});
|
||||
@Nullable final VideoPlayerUi ui = UIs.get(VideoPlayerUi.class);
|
||||
final PlayerBinding binding;
|
||||
if (ui != null) {
|
||||
binding = ui.getBinding();
|
||||
} else if (playerType == PlayerType.AUDIO) {
|
||||
binding = null;
|
||||
} else {
|
||||
binding = PlayerBinding.inflate(LayoutInflater.from(context));
|
||||
}
|
||||
|
||||
switch (playerType) {
|
||||
case MAIN:
|
||||
UIs.destroyAll(PopupPlayerUi.class);
|
||||
UIs.destroyAllOfType(PopupPlayerUi.class);
|
||||
UIs.addAndPrepare(new MainPlayerUi(this, binding));
|
||||
break;
|
||||
case POPUP:
|
||||
UIs.destroyAll(MainPlayerUi.class);
|
||||
UIs.destroyAllOfType(MainPlayerUi.class);
|
||||
UIs.addAndPrepare(new PopupPlayerUi(this, binding));
|
||||
break;
|
||||
case AUDIO:
|
||||
UIs.destroyAll(VideoPlayerUi.class);
|
||||
UIs.destroyAllOfType(VideoPlayerUi.class);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -687,9 +684,15 @@ public final class Player implements PlaybackListener, Listener {
|
||||
}
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
|
||||
/**
|
||||
* Shut down this player.
|
||||
* Saves the stream progress, sets recovery.
|
||||
* Then destroys the player in all UIs and destroys the UIs as well.
|
||||
*/
|
||||
public void saveAndShutdown() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "destroy() called");
|
||||
Log.d(TAG, "saveAndShutdown() called");
|
||||
}
|
||||
|
||||
saveStreamProgressState();
|
||||
@@ -702,9 +705,8 @@ public final class Player implements PlaybackListener, Listener {
|
||||
databaseUpdateDisposable.clear();
|
||||
progressUpdateDisposable.set(null);
|
||||
streamItemDisposable.clear();
|
||||
cancelLoadingCurrentThumbnail();
|
||||
|
||||
UIs.destroyAll(Object.class); // destroy every UI: obviously every UI extends Object
|
||||
UIs.destroyAllOfType(null);
|
||||
}
|
||||
|
||||
public void setRecovery() {
|
||||
@@ -876,67 +878,57 @@ public final class Player implements PlaybackListener, Listener {
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
//region Thumbnail loading
|
||||
|
||||
private Target getCurrentThumbnailTarget() {
|
||||
// a Picasso target is just a listener for thumbnail loading events
|
||||
return new Target() {
|
||||
@Override
|
||||
public void onBitmapLoaded(final Bitmap bitmap, final Picasso.LoadedFrom from) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Thumbnail - onBitmapLoaded() called with: bitmap = [" + bitmap
|
||||
+ " -> " + bitmap.getWidth() + "x" + bitmap.getHeight() + "], from = ["
|
||||
+ from + "]");
|
||||
}
|
||||
// there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too.
|
||||
onThumbnailLoaded(bitmap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBitmapFailed(final Exception e, final Drawable errorDrawable) {
|
||||
Log.e(TAG, "Thumbnail - onBitmapFailed() called", e);
|
||||
// there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too.
|
||||
onThumbnailLoaded(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepareLoad(final Drawable placeHolderDrawable) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Thumbnail - onPrepareLoad() called");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void loadCurrentThumbnail(final List<Image> thumbnails) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Thumbnail - loadCurrentThumbnail() called with thumbnails = ["
|
||||
+ thumbnails.size() + "]");
|
||||
}
|
||||
|
||||
// first cancel any previous loading
|
||||
cancelLoadingCurrentThumbnail();
|
||||
// Cancel any ongoing image loading
|
||||
if (thumbnailDisposable != null) {
|
||||
thumbnailDisposable.dispose();
|
||||
}
|
||||
|
||||
// Unset currentThumbnail, since it is now outdated. This ensures it is not used in media
|
||||
// session metadata while the new thumbnail is being loaded by Picasso.
|
||||
// session metadata while the new thumbnail is being loaded by Coil.
|
||||
onThumbnailLoaded(null);
|
||||
if (thumbnails.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// scale down the notification thumbnail for performance
|
||||
PicassoHelper.loadScaledDownThumbnail(context, thumbnails)
|
||||
.tag(PICASSO_PLAYER_THUMBNAIL_TAG)
|
||||
.into(currentThumbnailTarget);
|
||||
}
|
||||
final var thumbnailTarget = new Target() {
|
||||
@Override
|
||||
public void onError(@Nullable final coil3.Image error) {
|
||||
Log.e(TAG, "Thumbnail - onError() called");
|
||||
// there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too.
|
||||
onThumbnailLoaded(null);
|
||||
}
|
||||
|
||||
private void cancelLoadingCurrentThumbnail() {
|
||||
// cancel the Picasso job associated with the player thumbnail, if any
|
||||
PicassoHelper.cancelTag(PICASSO_PLAYER_THUMBNAIL_TAG);
|
||||
@Override
|
||||
public void onStart(@Nullable final coil3.Image placeholder) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Thumbnail - onStart() called");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSuccess(@NonNull final coil3.Image result) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Thumbnail - onSuccess() called with: drawable = [" + result + "]");
|
||||
}
|
||||
// there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too.
|
||||
onThumbnailLoaded(toBitmap(result));
|
||||
}
|
||||
};
|
||||
thumbnailDisposable = CoilHelper.INSTANCE
|
||||
.loadScaledDownThumbnail(context, thumbnails, thumbnailTarget);
|
||||
}
|
||||
|
||||
private void onThumbnailLoaded(@Nullable final Bitmap bitmap) {
|
||||
// Avoid useless thumbnail updates, if the thumbnail has not actually changed. Based on the
|
||||
// thumbnail loading code, this if would be skipped only when both bitmaps are `null`, since
|
||||
// onThumbnailLoaded won't be called twice with the same nonnull bitmap by Picasso's target.
|
||||
// onThumbnailLoaded won't be called twice with the same nonnull bitmap by Coil's target.
|
||||
if (currentThumbnail != bitmap) {
|
||||
currentThumbnail = bitmap;
|
||||
UIs.call(playerUi -> playerUi.onThumbnailLoaded(bitmap));
|
||||
@@ -1700,7 +1692,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
}
|
||||
|
||||
// sync the player index with the queue index, and seek to the correct position
|
||||
if (item.getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) {
|
||||
if (item.getRecoveryPosition() != Long.MIN_VALUE) {
|
||||
simpleExoPlayer.seekTo(playQueueIndex, item.getRecoveryPosition());
|
||||
playQueue.unsetRecovery(playQueueIndex);
|
||||
} else {
|
||||
@@ -2113,6 +2105,10 @@ public final class Player implements PlaybackListener, Listener {
|
||||
triggerProgressUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the listener, if it was set.
|
||||
* @param listener listener to remove
|
||||
* */
|
||||
public void removeFragmentListener(final PlayerServiceEventListener listener) {
|
||||
if (fragmentListener == listener) {
|
||||
fragmentListener = null;
|
||||
@@ -2127,6 +2123,10 @@ public final class Player implements PlaybackListener, Listener {
|
||||
triggerProgressUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the listener, if it was set.
|
||||
* @param listener listener to remove
|
||||
* */
|
||||
void removeActivityListener(final PlayerEventListener listener) {
|
||||
if (activityListener == listener) {
|
||||
activityListener = null;
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
/*
|
||||
* Copyright 2017 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* 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.player;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Binder;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.support.v4.media.MediaBrowserCompat;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.ServiceCompat;
|
||||
import androidx.media.MediaBrowserServiceCompat;
|
||||
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||
|
||||
import org.schabi.newpipe.ktx.BundleKt;
|
||||
import org.schabi.newpipe.player.mediabrowser.MediaBrowserImpl;
|
||||
import org.schabi.newpipe.player.mediabrowser.MediaBrowserPlaybackPreparer;
|
||||
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
|
||||
import org.schabi.newpipe.player.notification.NotificationPlayerUi;
|
||||
import org.schabi.newpipe.player.notification.NotificationUtil;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
|
||||
/**
|
||||
* One service for all players.
|
||||
*/
|
||||
public final class PlayerService extends MediaBrowserServiceCompat {
|
||||
private static final String TAG = PlayerService.class.getSimpleName();
|
||||
private static final boolean DEBUG = Player.DEBUG;
|
||||
|
||||
public static final String SHOULD_START_FOREGROUND_EXTRA = "should_start_foreground_extra";
|
||||
public static final String BIND_PLAYER_HOLDER_ACTION = "bind_player_holder_action";
|
||||
|
||||
// These objects are used to cleanly separate the Service implementation (in this file) and the
|
||||
// media browser and playback preparer implementations. At the moment the playback preparer is
|
||||
// only used in conjunction with the media browser.
|
||||
private MediaBrowserImpl mediaBrowserImpl;
|
||||
private MediaBrowserPlaybackPreparer mediaBrowserPlaybackPreparer;
|
||||
|
||||
// these are instantiated in onCreate() as per
|
||||
// https://developer.android.com/training/cars/media#browser_workflow
|
||||
private MediaSessionCompat mediaSession;
|
||||
private MediaSessionConnector sessionConnector;
|
||||
|
||||
@Nullable
|
||||
private Player player;
|
||||
|
||||
private final IBinder mBinder = new PlayerService.LocalBinder(this);
|
||||
|
||||
/**
|
||||
* The parameter taken by this {@link Consumer} can be null to indicate the player is being
|
||||
* stopped.
|
||||
*/
|
||||
@Nullable
|
||||
private Consumer<Player> onPlayerStartedOrStopped = null;
|
||||
|
||||
|
||||
//region Service lifecycle
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreate() called");
|
||||
}
|
||||
ThemeHelper.setTheme(this);
|
||||
|
||||
mediaBrowserImpl = new MediaBrowserImpl(this, this::notifyChildrenChanged);
|
||||
|
||||
// see https://developer.android.com/training/cars/media#browser_workflow
|
||||
mediaSession = new MediaSessionCompat(this, "MediaSessionPlayerServ");
|
||||
setSessionToken(mediaSession.getSessionToken());
|
||||
sessionConnector = new MediaSessionConnector(mediaSession);
|
||||
sessionConnector.setMetadataDeduplicationEnabled(true);
|
||||
|
||||
mediaBrowserPlaybackPreparer = new MediaBrowserPlaybackPreparer(
|
||||
this,
|
||||
sessionConnector::setCustomErrorMessage,
|
||||
() -> sessionConnector.setCustomErrorMessage(null),
|
||||
(playWhenReady) -> {
|
||||
if (player != null) {
|
||||
player.onPrepare();
|
||||
}
|
||||
}
|
||||
);
|
||||
sessionConnector.setPlaybackPreparer(mediaBrowserPlaybackPreparer);
|
||||
|
||||
// Note: you might be tempted to create the player instance and call startForeground here,
|
||||
// but be aware that the Android system might start the service just to perform media
|
||||
// queries. In those cases creating a player instance is a waste of resources, and calling
|
||||
// startForeground means creating a useless empty notification. In case it's really needed
|
||||
// the player instance can be created here, but startForeground() should definitely not be
|
||||
// called here unless the service is actually starting in the foreground, to avoid the
|
||||
// useless notification.
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(final Intent intent, final int flags, final int startId) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onStartCommand() called with: intent = [" + intent
|
||||
+ "], extras = [" + BundleKt.toDebugString(intent.getExtras())
|
||||
+ "], flags = [" + flags + "], startId = [" + startId + "]");
|
||||
}
|
||||
|
||||
// All internal NewPipe intents used to interact with the player, that are sent to the
|
||||
// PlayerService using startForegroundService(), will have SHOULD_START_FOREGROUND_EXTRA,
|
||||
// to ensure startForeground() is called (otherwise Android will force-crash the app).
|
||||
if (intent.getBooleanExtra(SHOULD_START_FOREGROUND_EXTRA, false)) {
|
||||
final boolean playerWasNull = (player == null);
|
||||
if (playerWasNull) {
|
||||
// make sure the player exists, in case the service was resumed
|
||||
player = new Player(this, mediaSession, sessionConnector);
|
||||
}
|
||||
|
||||
// Be sure that the player notification is set and the service is started in foreground,
|
||||
// otherwise, the app may crash on Android 8+ as the service would never be put in the
|
||||
// foreground while we said to the system we would do so. The service is always
|
||||
// requested to be started in foreground, so always creating a notification if there is
|
||||
// no one already and starting the service in foreground should not create any issues.
|
||||
// If the service is already started in foreground, requesting it to be started
|
||||
// shouldn't do anything.
|
||||
player.UIs().get(NotificationPlayerUi.class)
|
||||
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
|
||||
|
||||
if (playerWasNull && onPlayerStartedOrStopped != null) {
|
||||
// notify that a new player was created (but do it after creating the foreground
|
||||
// notification just to make sure we don't incur, due to slowness, in
|
||||
// "Context.startForegroundService() did not then call Service.startForeground()")
|
||||
onPlayerStartedOrStopped.accept(player);
|
||||
}
|
||||
}
|
||||
|
||||
if (player == null) {
|
||||
// No need to process media button's actions or other system intents if the player is
|
||||
// not running. However, since the current intent might have been issued by the system
|
||||
// with `startForegroundService()` (for unknown reasons), we need to ensure that we post
|
||||
// a (dummy) foreground notification, otherwise we'd incur in
|
||||
// "Context.startForegroundService() did not then call Service.startForeground()". Then
|
||||
// we stop the service again.
|
||||
Log.d(TAG, "onStartCommand() got a useless intent, closing the service");
|
||||
NotificationUtil.startForegroundWithDummyNotification(this);
|
||||
destroyPlayerAndStopService();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
final PlayerType oldPlayerType = player.getPlayerType();
|
||||
player.handleIntent(intent);
|
||||
player.handleIntentPost(oldPlayerType);
|
||||
player.UIs().get(MediaSessionPlayerUi.class)
|
||||
.ifPresent(ui -> ui.handleMediaButtonIntent(intent));
|
||||
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
public void stopForImmediateReusing() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "stopForImmediateReusing() called");
|
||||
}
|
||||
|
||||
if (player != null && !player.exoPlayerIsNull()) {
|
||||
// Releases wifi & cpu, disables keepScreenOn, etc.
|
||||
// We can't just pause the player here because it will make transition
|
||||
// from one stream to a new stream not smooth
|
||||
player.smoothStopForImmediateReusing();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTaskRemoved(final Intent rootIntent) {
|
||||
super.onTaskRemoved(rootIntent);
|
||||
if (player != null && !player.videoPlayerSelected()) {
|
||||
return;
|
||||
}
|
||||
onDestroy();
|
||||
// Unload from memory completely
|
||||
Runtime.getRuntime().halt(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "destroy() called");
|
||||
}
|
||||
super.onDestroy();
|
||||
|
||||
cleanup();
|
||||
|
||||
mediaBrowserPlaybackPreparer.dispose();
|
||||
mediaSession.release();
|
||||
mediaBrowserImpl.dispose();
|
||||
}
|
||||
|
||||
private void cleanup() {
|
||||
if (player != null) {
|
||||
if (onPlayerStartedOrStopped != null) {
|
||||
// notify that the player is being destroyed
|
||||
onPlayerStartedOrStopped.accept(null);
|
||||
}
|
||||
player.destroy();
|
||||
player = null;
|
||||
}
|
||||
|
||||
// Should already be handled by MediaSessionPlayerUi, but just to be sure.
|
||||
mediaSession.setActive(false);
|
||||
|
||||
// Should already be handled by NotificationUtil.cancelNotificationAndStopForeground() in
|
||||
// NotificationPlayerUi, but let's make sure that the foreground service is stopped.
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the player and allows the player instance to be garbage collected. Sets the media
|
||||
* session to inactive. Stops the foreground service and removes the player notification
|
||||
* associated with it. Tries to stop the {@link PlayerService} completely, but this step will
|
||||
* have no effect in case some service connection still uses the service (e.g. the Android Auto
|
||||
* system accesses the media browser even when no player is running).
|
||||
*/
|
||||
public void destroyPlayerAndStopService() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "destroyPlayerAndStopService() called");
|
||||
}
|
||||
|
||||
cleanup();
|
||||
|
||||
// This only really stops the service if there are no other service connections (see docs):
|
||||
// for example the (Android Auto) media browser binder will block stopService().
|
||||
// This is why we also stopForeground() above, to make sure the notification is removed.
|
||||
// If we were to call stopSelf(), then the service would be surely stopped (regardless of
|
||||
// other service connections), but this would be a waste of resources since the service
|
||||
// would be immediately restarted by those same connections to perform the queries.
|
||||
stopService(new Intent(this, PlayerService.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(final Context base) {
|
||||
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base));
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Bind
|
||||
@Override
|
||||
public IBinder onBind(final Intent intent) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onBind() called with: intent = [" + intent
|
||||
+ "], extras = [" + BundleKt.toDebugString(intent.getExtras()) + "]");
|
||||
}
|
||||
|
||||
if (BIND_PLAYER_HOLDER_ACTION.equals(intent.getAction())) {
|
||||
// Note that this binder might be reused multiple times while the service is alive, even
|
||||
// after unbind() has been called: https://stackoverflow.com/a/8794930 .
|
||||
return mBinder;
|
||||
|
||||
} else if (MediaBrowserServiceCompat.SERVICE_INTERFACE.equals(intent.getAction())) {
|
||||
// MediaBrowserService also uses its own binder, so for actions related to the media
|
||||
// browser service, pass the onBind to the superclass.
|
||||
return super.onBind(intent);
|
||||
|
||||
} else {
|
||||
// This is an unknown request, avoid returning any binder to not leak objects.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static class LocalBinder extends Binder {
|
||||
private final WeakReference<PlayerService> playerService;
|
||||
|
||||
LocalBinder(final PlayerService playerService) {
|
||||
this.playerService = new WeakReference<>(playerService);
|
||||
}
|
||||
|
||||
public PlayerService getService() {
|
||||
return playerService.get();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the current active player instance. May be null, since the player service can outlive
|
||||
* the player e.g. to respond to Android Auto media browser queries.
|
||||
*/
|
||||
@Nullable
|
||||
public Player getPlayer() {
|
||||
return player;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the listener that will be called when the player is started or stopped. If a
|
||||
* {@code null} listener is passed, then the current listener will be unset. The parameter taken
|
||||
* by the {@link Consumer} can be null to indicate that the player is stopping.
|
||||
* @param listener the listener to set or unset
|
||||
*/
|
||||
public void setPlayerListener(@Nullable final Consumer<Player> listener) {
|
||||
this.onPlayerStartedOrStopped = listener;
|
||||
if (listener != null) {
|
||||
// if there is no player, then `null` will be sent here, to ensure the state is synced
|
||||
listener.accept(player);
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Media browser
|
||||
@Override
|
||||
public BrowserRoot onGetRoot(@NonNull final String clientPackageName,
|
||||
final int clientUid,
|
||||
@Nullable final Bundle rootHints) {
|
||||
return mediaBrowserImpl.onGetRoot(clientPackageName, clientUid, rootHints);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadChildren(@NonNull final String parentId,
|
||||
@NonNull final Result<List<MediaBrowserCompat.MediaItem>> result) {
|
||||
mediaBrowserImpl.onLoadChildren(parentId, result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSearch(@NonNull final String query,
|
||||
final Bundle extras,
|
||||
@NonNull final Result<List<MediaBrowserCompat.MediaItem>> result) {
|
||||
mediaBrowserImpl.onSearch(query, result);
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
323
app/src/main/java/org/schabi/newpipe/player/PlayerService.kt
Normal file
323
app/src/main/java/org/schabi/newpipe/player/PlayerService.kt
Normal file
@@ -0,0 +1,323 @@
|
||||
/*
|
||||
* Copyright 2017 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* 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.player
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.util.Log
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
import org.schabi.newpipe.ktx.toDebugString
|
||||
import org.schabi.newpipe.player.mediabrowser.MediaBrowserImpl
|
||||
import org.schabi.newpipe.player.mediabrowser.MediaBrowserPlaybackPreparer
|
||||
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi
|
||||
import org.schabi.newpipe.player.notification.NotificationPlayerUi
|
||||
import org.schabi.newpipe.player.notification.NotificationUtil
|
||||
import org.schabi.newpipe.util.ThemeHelper
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
* One service for all players.
|
||||
*/
|
||||
class PlayerService : MediaBrowserServiceCompat() {
|
||||
// These objects are used to cleanly separate the Service implementation (in this file) and the
|
||||
// media browser and playback preparer implementations. At the moment the playback preparer is
|
||||
// only used in conjunction with the media browser.
|
||||
private lateinit var mediaBrowserImpl: MediaBrowserImpl
|
||||
private lateinit var mediaBrowserPlaybackPreparer: MediaBrowserPlaybackPreparer
|
||||
|
||||
// these are instantiated in onCreate() as per
|
||||
// https://developer.android.com/training/cars/media#browser_workflow
|
||||
private lateinit var mediaSession: MediaSessionCompat
|
||||
private lateinit var sessionConnector: MediaSessionConnector
|
||||
|
||||
/**
|
||||
* @return the current active player instance. May be null, since the player service can outlive
|
||||
* the player e.g. to respond to Android Auto media browser queries.
|
||||
*/
|
||||
var player: Player? = null
|
||||
private set
|
||||
|
||||
private val mBinder: IBinder = LocalBinder(this)
|
||||
|
||||
/**
|
||||
* The parameter taken by this [Consumer] can be null to indicate the player is being
|
||||
* stopped.
|
||||
*/
|
||||
private var onPlayerStartedOrStopped: ((player: Player?) -> Unit)? = null
|
||||
|
||||
//region Service lifecycle
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreate() called")
|
||||
}
|
||||
ThemeHelper.setTheme(this)
|
||||
|
||||
mediaBrowserImpl = MediaBrowserImpl(this, this::notifyChildrenChanged)
|
||||
|
||||
// see https://developer.android.com/training/cars/media#browser_workflow
|
||||
val session = MediaSessionCompat(this, "MediaSessionPlayerServ")
|
||||
mediaSession = session
|
||||
setSessionToken(session.sessionToken)
|
||||
val connector = MediaSessionConnector(session)
|
||||
sessionConnector = connector
|
||||
connector.setMetadataDeduplicationEnabled(true)
|
||||
|
||||
mediaBrowserPlaybackPreparer = MediaBrowserPlaybackPreparer(
|
||||
context = this,
|
||||
setMediaSessionError = connector::setCustomErrorMessage,
|
||||
clearMediaSessionError = { connector.setCustomErrorMessage(null) },
|
||||
onPrepare = { player?.onPrepare() }
|
||||
)
|
||||
connector.setPlaybackPreparer(mediaBrowserPlaybackPreparer)
|
||||
|
||||
// Note: you might be tempted to create the player instance and call startForeground here,
|
||||
// but be aware that the Android system might start the service just to perform media
|
||||
// queries. In those cases creating a player instance is a waste of resources, and calling
|
||||
// startForeground means creating a useless empty notification. In case it's really needed
|
||||
// the player instance can be created here, but startForeground() should definitely not be
|
||||
// called here unless the service is actually starting in the foreground, to avoid the
|
||||
// useless notification.
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
if (DEBUG) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"onStartCommand() called with: intent = [$intent], extras = [${
|
||||
intent.extras.toDebugString()}], flags = [$flags], startId = [$startId]"
|
||||
)
|
||||
}
|
||||
|
||||
// All internal NewPipe intents used to interact with the player, that are sent to the
|
||||
// PlayerService using startForegroundService(), will have SHOULD_START_FOREGROUND_EXTRA,
|
||||
// to ensure startForeground() is called (otherwise Android will force-crash the app).
|
||||
if (intent.getBooleanExtra(SHOULD_START_FOREGROUND_EXTRA, false)) {
|
||||
val playerWasNull = (player == null)
|
||||
if (playerWasNull) {
|
||||
// make sure the player exists, in case the service was resumed
|
||||
player = Player(this, mediaSession, sessionConnector)
|
||||
}
|
||||
|
||||
// Be sure that the player notification is set and the service is started in foreground,
|
||||
// otherwise, the app may crash on Android 8+ as the service would never be put in the
|
||||
// foreground while we said to the system we would do so. The service is always
|
||||
// requested to be started in foreground, so always creating a notification if there is
|
||||
// no one already and starting the service in foreground should not create any issues.
|
||||
// If the service is already started in foreground, requesting it to be started
|
||||
// shouldn't do anything.
|
||||
player?.UIs()?.get(NotificationPlayerUi::class)?.createNotificationAndStartForeground()
|
||||
|
||||
if (playerWasNull) {
|
||||
// notify that a new player was created (but do it after creating the foreground
|
||||
// notification just to make sure we don't incur, due to slowness, in
|
||||
// "Context.startForegroundService() did not then call Service.startForeground()")
|
||||
onPlayerStartedOrStopped?.invoke(player)
|
||||
}
|
||||
}
|
||||
|
||||
if (player == null) {
|
||||
// No need to process media button's actions or other system intents if the player is
|
||||
// not running. However, since the current intent might have been issued by the system
|
||||
// with `startForegroundService()` (for unknown reasons), we need to ensure that we post
|
||||
// a (dummy) foreground notification, otherwise we'd incur in
|
||||
// "Context.startForegroundService() did not then call Service.startForeground()". Then
|
||||
// we stop the service again.
|
||||
Log.d(TAG, "onStartCommand() got a useless intent, closing the service")
|
||||
NotificationUtil.startForegroundWithDummyNotification(this)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val oldPlayerType = player?.playerType
|
||||
player?.handleIntent(intent)
|
||||
player?.handleIntentPost(oldPlayerType)
|
||||
player?.UIs()?.get(MediaSessionPlayerUi::class.java)
|
||||
?.handleMediaButtonIntent(intent)
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
fun stopForImmediateReusing() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "stopForImmediateReusing() called")
|
||||
}
|
||||
|
||||
val p = player
|
||||
if (p != null && !p.exoPlayerIsNull()) {
|
||||
// Releases wifi & cpu, disables keepScreenOn, etc.
|
||||
// We can't just pause the player here because it will make transition
|
||||
// from one stream to a new stream not smooth
|
||||
p.smoothStopForImmediateReusing()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
val p = player
|
||||
if (p != null && !p.videoPlayerSelected()) {
|
||||
return
|
||||
}
|
||||
onDestroy()
|
||||
// Unload from memory completely
|
||||
Runtime.getRuntime().halt(0)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "destroy() called")
|
||||
}
|
||||
super.onDestroy()
|
||||
|
||||
cleanup()
|
||||
|
||||
mediaBrowserPlaybackPreparer.dispose()
|
||||
mediaSession.release()
|
||||
mediaBrowserImpl.dispose()
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
val p = player
|
||||
if (p != null) {
|
||||
// notify that the player is being destroyed
|
||||
onPlayerStartedOrStopped?.invoke(null)
|
||||
p.saveAndShutdown()
|
||||
player = null
|
||||
}
|
||||
|
||||
// Should already be handled by MediaSessionPlayerUi, but just to be sure.
|
||||
mediaSession.setActive(false)
|
||||
|
||||
// Should already be handled by NotificationUtil.cancelNotificationAndStopForeground() in
|
||||
// NotificationPlayerUi, but let's make sure that the foreground service is stopped.
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the player and allows the player instance to be garbage collected. Sets the media
|
||||
* session to inactive. Stops the foreground service and removes the player notification
|
||||
* associated with it. Tries to stop the [PlayerService] completely, but this step will
|
||||
* have no effect in case some service connection still uses the service (e.g. the Android Auto
|
||||
* system accesses the media browser even when no player is running).
|
||||
*/
|
||||
fun destroyPlayerAndStopService() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "destroyPlayerAndStopService() called")
|
||||
}
|
||||
|
||||
cleanup()
|
||||
|
||||
// This only really stops the service if there are no other service connections (see docs):
|
||||
// for example the (Android Auto) media browser binder will block stopService().
|
||||
// This is why we also stopForeground() above, to make sure the notification is removed.
|
||||
// If we were to call stopSelf(), then the service would be surely stopped (regardless of
|
||||
// other service connections), but this would be a waste of resources since the service
|
||||
// would be immediately restarted by those same connections to perform the queries.
|
||||
stopService(Intent(this, PlayerService::class.java))
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base))
|
||||
}
|
||||
|
||||
//endregion
|
||||
//region Bind
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
if (DEBUG) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"onBind() called with: intent = [$intent], extras = [${
|
||||
intent.extras.toDebugString()}]"
|
||||
)
|
||||
}
|
||||
|
||||
return if (BIND_PLAYER_HOLDER_ACTION == intent.action) {
|
||||
// Note that this binder might be reused multiple times while the service is alive, even
|
||||
// after unbind() has been called: https://stackoverflow.com/a/8794930 .
|
||||
mBinder
|
||||
} else if (SERVICE_INTERFACE == intent.action) {
|
||||
// MediaBrowserService also uses its own binder, so for actions related to the media
|
||||
// browser service, pass the onBind to the superclass.
|
||||
super.onBind(intent)
|
||||
} else {
|
||||
// This is an unknown request, avoid returning any binder to not leak objects.
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
class LocalBinder internal constructor(playerService: PlayerService) : Binder() {
|
||||
private val playerService = WeakReference(playerService)
|
||||
|
||||
val service: PlayerService?
|
||||
get() = playerService.get()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the listener that will be called when the player is started or stopped. If a
|
||||
* `null` listener is passed, then the current listener will be unset. The parameter taken
|
||||
* by the [Consumer] can be null to indicate that the player is stopping.
|
||||
* @param listener the listener to set or unset
|
||||
*/
|
||||
fun setPlayerListener(listener: ((player: Player?) -> Unit)?) {
|
||||
this.onPlayerStartedOrStopped = listener
|
||||
listener?.invoke(player)
|
||||
}
|
||||
|
||||
//endregion
|
||||
//region Media browser
|
||||
override fun onGetRoot(
|
||||
clientPackageName: String,
|
||||
clientUid: Int,
|
||||
rootHints: Bundle?
|
||||
): BrowserRoot? {
|
||||
// TODO check if the accessing package has permission to view data
|
||||
return mediaBrowserImpl.onGetRoot(clientPackageName, clientUid, rootHints)
|
||||
}
|
||||
|
||||
override fun onLoadChildren(
|
||||
parentId: String,
|
||||
result: Result<List<MediaBrowserCompat.MediaItem>>
|
||||
) {
|
||||
mediaBrowserImpl.onLoadChildren(parentId, result)
|
||||
}
|
||||
|
||||
override fun onSearch(
|
||||
query: String,
|
||||
extras: Bundle?,
|
||||
result: Result<List<MediaBrowserCompat.MediaItem>>
|
||||
) {
|
||||
mediaBrowserImpl.onSearch(query, result)
|
||||
} //endregion
|
||||
|
||||
companion object {
|
||||
private val TAG: String = PlayerService::class.java.getSimpleName()
|
||||
private val DEBUG = Player.DEBUG
|
||||
|
||||
const val SHOULD_START_FOREGROUND_EXTRA: String = "should_start_foreground_extra"
|
||||
const val BIND_PLAYER_HOLDER_ACTION: String = "bind_player_holder_action"
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.google.android.exoplayer2.PlaybackParameters;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
|
||||
/** Player-specific events like queue or progress updates. */
|
||||
public interface PlayerEventListener {
|
||||
void onQueueUpdate(PlayQueue queue);
|
||||
void onPlaybackUpdate(int state, int repeatMode, boolean shuffled,
|
||||
|
||||
@@ -2,12 +2,15 @@ package org.schabi.newpipe.player.event;
|
||||
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
|
||||
/** {@link org.schabi.newpipe.player.event.PlayerEventListener} that also gets called for
|
||||
* application-specific events like screen rotation or UI changes.
|
||||
*/
|
||||
public interface PlayerServiceEventListener extends PlayerEventListener {
|
||||
void onViewCreated();
|
||||
|
||||
void onFullscreenStateChanged(boolean fullscreen);
|
||||
|
||||
void onScreenRotationButtonClicked();
|
||||
void onFullscreenToggleButtonClicked();
|
||||
|
||||
void onMoreOptionsLongClicked();
|
||||
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
package org.schabi.newpipe.player.helper;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.ServiceConnection;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
|
||||
import org.schabi.newpipe.App;
|
||||
import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.player.PlayerService;
|
||||
import org.schabi.newpipe.player.Player;
|
||||
import org.schabi.newpipe.player.PlayerType;
|
||||
import org.schabi.newpipe.player.event.PlayerServiceEventListener;
|
||||
import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class PlayerHolder {
|
||||
|
||||
private PlayerHolder() {
|
||||
}
|
||||
|
||||
private static PlayerHolder instance;
|
||||
public static synchronized PlayerHolder getInstance() {
|
||||
if (PlayerHolder.instance == null) {
|
||||
PlayerHolder.instance = new PlayerHolder();
|
||||
}
|
||||
return PlayerHolder.instance;
|
||||
}
|
||||
|
||||
private static final boolean DEBUG = MainActivity.DEBUG;
|
||||
private static final String TAG = PlayerHolder.class.getSimpleName();
|
||||
|
||||
@Nullable private PlayerServiceExtendedEventListener listener;
|
||||
|
||||
private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection();
|
||||
private boolean bound;
|
||||
@Nullable private PlayerService playerService;
|
||||
|
||||
private Optional<Player> getPlayer() {
|
||||
return Optional.ofNullable(playerService)
|
||||
.flatMap(s -> Optional.ofNullable(s.getPlayer()));
|
||||
}
|
||||
|
||||
private Optional<PlayQueue> getPlayQueue() {
|
||||
// player play queue might be null e.g. while player is starting
|
||||
return getPlayer().flatMap(p -> Optional.ofNullable(p.getPlayQueue()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current {@link PlayerType} of the {@link PlayerService} service,
|
||||
* otherwise `null` if no service is running.
|
||||
*
|
||||
* @return Current PlayerType
|
||||
*/
|
||||
@Nullable
|
||||
public PlayerType getType() {
|
||||
return getPlayer().map(Player::getPlayerType).orElse(null);
|
||||
}
|
||||
|
||||
public boolean isPlaying() {
|
||||
return getPlayer().map(Player::isPlaying).orElse(false);
|
||||
}
|
||||
|
||||
public boolean isPlayerOpen() {
|
||||
return getPlayer().isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this method to only allow the user to manipulate the play queue (e.g. by enqueueing via
|
||||
* the stream long press menu) when there actually is a play queue to manipulate.
|
||||
* @return true only if the player is open and its play queue is ready (i.e. it is not null)
|
||||
*/
|
||||
public boolean isPlayQueueReady() {
|
||||
return getPlayQueue().isPresent();
|
||||
}
|
||||
|
||||
public boolean isBound() {
|
||||
return bound;
|
||||
}
|
||||
|
||||
public int getQueueSize() {
|
||||
return getPlayQueue().map(PlayQueue::size).orElse(0);
|
||||
}
|
||||
|
||||
public int getQueuePosition() {
|
||||
return getPlayQueue().map(PlayQueue::getIndex).orElse(0);
|
||||
}
|
||||
|
||||
public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) {
|
||||
listener = newListener;
|
||||
|
||||
if (listener == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force reload data from service
|
||||
if (playerService != null) {
|
||||
listener.onServiceConnected(playerService);
|
||||
startPlayerListener();
|
||||
// ^ will call listener.onPlayerConnected() down the line if there is an active player
|
||||
}
|
||||
}
|
||||
|
||||
// helper to handle context in common place as using the same
|
||||
// context to bind/unbind a service is crucial
|
||||
private Context getCommonContext() {
|
||||
return App.getApp();
|
||||
}
|
||||
|
||||
public void startService(final boolean playAfterConnect,
|
||||
final PlayerServiceExtendedEventListener newListener) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "startService() called with playAfterConnect=" + playAfterConnect);
|
||||
}
|
||||
final Context context = getCommonContext();
|
||||
setListener(newListener);
|
||||
if (bound) {
|
||||
return;
|
||||
}
|
||||
// startService() can be called concurrently and it will give a random crashes
|
||||
// and NullPointerExceptions inside the service because the service will be
|
||||
// bound twice. Prevent it with unbinding first
|
||||
unbind(context);
|
||||
final Intent intent = new Intent(context, PlayerService.class);
|
||||
intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true);
|
||||
ContextCompat.startForegroundService(context, intent);
|
||||
serviceConnection.doPlayAfterConnect(playAfterConnect);
|
||||
bind(context);
|
||||
}
|
||||
|
||||
public void stopService() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "stopService() called");
|
||||
}
|
||||
if (playerService != null) {
|
||||
playerService.destroyPlayerAndStopService();
|
||||
}
|
||||
final Context context = getCommonContext();
|
||||
unbind(context);
|
||||
// destroyPlayerAndStopService() already runs the next line of code, but run it again just
|
||||
// to make sure to stop the service even if playerService is null by any chance.
|
||||
context.stopService(new Intent(context, PlayerService.class));
|
||||
}
|
||||
|
||||
class PlayerServiceConnection implements ServiceConnection {
|
||||
|
||||
private boolean playAfterConnect = false;
|
||||
|
||||
/**
|
||||
* @param playAfterConnection Sets the value of `playAfterConnect` to pass to the {@link
|
||||
* PlayerServiceExtendedEventListener#onPlayerConnected(Player, boolean)} the next time it
|
||||
* is called. The value of `playAfterConnect` will be reset to false after that.
|
||||
*/
|
||||
public void doPlayAfterConnect(final boolean playAfterConnection) {
|
||||
this.playAfterConnect = playAfterConnection;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected(final ComponentName compName) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Player service is disconnected");
|
||||
}
|
||||
|
||||
final Context context = getCommonContext();
|
||||
unbind(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceConnected(final ComponentName compName, final IBinder service) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Player service is connected");
|
||||
}
|
||||
final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service;
|
||||
|
||||
playerService = localBinder.getService();
|
||||
if (listener != null) {
|
||||
listener.onServiceConnected(playerService);
|
||||
}
|
||||
startPlayerListener();
|
||||
// ^ will call listener.onPlayerConnected() down the line if there is an active player
|
||||
|
||||
if (playerService != null && playerService.getPlayer() != null) {
|
||||
// notify the main activity that binding the service has completed and that there is
|
||||
// a player, so that it can open the bottom mini-player
|
||||
NavigationHelper.sendPlayerStartedEvent(localBinder.getService());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void bind(final Context context) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "bind() called");
|
||||
}
|
||||
// BIND_AUTO_CREATE starts the service if it's not already running
|
||||
bound = bind(context, Context.BIND_AUTO_CREATE);
|
||||
if (!bound) {
|
||||
context.unbindService(serviceConnection);
|
||||
}
|
||||
}
|
||||
|
||||
public void tryBindIfNeeded(final Context context) {
|
||||
if (!bound) {
|
||||
// flags=0 means the service will not be started if it does not already exist. In this
|
||||
// case the return value is not useful, as a value of "true" does not really indicate
|
||||
// that the service is going to be bound.
|
||||
bind(context, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean bind(final Context context, final int flags) {
|
||||
final Intent serviceIntent = new Intent(context, PlayerService.class);
|
||||
serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
|
||||
return context.bindService(serviceIntent, serviceConnection, flags);
|
||||
}
|
||||
|
||||
private void unbind(final Context context) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "unbind() called");
|
||||
}
|
||||
|
||||
if (bound) {
|
||||
context.unbindService(serviceConnection);
|
||||
bound = false;
|
||||
stopPlayerListener();
|
||||
playerService = null;
|
||||
if (listener != null) {
|
||||
listener.onPlayerDisconnected();
|
||||
listener.onServiceDisconnected();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void startPlayerListener() {
|
||||
if (playerService != null) {
|
||||
// setting the player listener will take care of calling relevant callbacks if the
|
||||
// player in the service is (not) already active, also see playerStateListener below
|
||||
playerService.setPlayerListener(playerStateListener);
|
||||
}
|
||||
getPlayer().ifPresent(p -> p.setFragmentListener(internalListener));
|
||||
}
|
||||
|
||||
private void stopPlayerListener() {
|
||||
if (playerService != null) {
|
||||
playerService.setPlayerListener(null);
|
||||
}
|
||||
getPlayer().ifPresent(p -> p.removeFragmentListener(internalListener));
|
||||
}
|
||||
|
||||
/**
|
||||
* This listener will be held by the players created by {@link PlayerService}.
|
||||
*/
|
||||
private final PlayerServiceEventListener internalListener =
|
||||
new PlayerServiceEventListener() {
|
||||
@Override
|
||||
public void onViewCreated() {
|
||||
if (listener != null) {
|
||||
listener.onViewCreated();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFullscreenStateChanged(final boolean fullscreen) {
|
||||
if (listener != null) {
|
||||
listener.onFullscreenStateChanged(fullscreen);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScreenRotationButtonClicked() {
|
||||
if (listener != null) {
|
||||
listener.onScreenRotationButtonClicked();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMoreOptionsLongClicked() {
|
||||
if (listener != null) {
|
||||
listener.onMoreOptionsLongClicked();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerError(final PlaybackException error,
|
||||
final boolean isCatchableException) {
|
||||
if (listener != null) {
|
||||
listener.onPlayerError(error, isCatchableException);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hideSystemUiIfNeeded() {
|
||||
if (listener != null) {
|
||||
listener.hideSystemUiIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onQueueUpdate(final PlayQueue queue) {
|
||||
if (listener != null) {
|
||||
listener.onQueueUpdate(queue);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlaybackUpdate(final int state,
|
||||
final int repeatMode,
|
||||
final boolean shuffled,
|
||||
final PlaybackParameters parameters) {
|
||||
if (listener != null) {
|
||||
listener.onPlaybackUpdate(state, repeatMode, shuffled, parameters);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProgressUpdate(final int currentProgress,
|
||||
final int duration,
|
||||
final int bufferPercent) {
|
||||
if (listener != null) {
|
||||
listener.onProgressUpdate(currentProgress, duration, bufferPercent);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMetadataUpdate(final StreamInfo info, final PlayQueue queue) {
|
||||
if (listener != null) {
|
||||
listener.onMetadataUpdate(info, queue);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceStopped() {
|
||||
if (listener != null) {
|
||||
listener.onServiceStopped();
|
||||
}
|
||||
unbind(getCommonContext());
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This listener will be held by bound {@link PlayerService}s to notify of the player starting
|
||||
* or stopping. This is necessary since the service outlives the player e.g. to answer Android
|
||||
* Auto media browser queries.
|
||||
*/
|
||||
private final Consumer<Player> playerStateListener = (@Nullable final Player player) -> {
|
||||
if (listener != null) {
|
||||
if (player == null) {
|
||||
// player.fragmentListener=null is already done by player.stopActivityBinding(),
|
||||
// which is called by player.destroy(), which is in turn called by PlayerService
|
||||
// before setting its player to null
|
||||
listener.onPlayerDisconnected();
|
||||
} else {
|
||||
listener.onPlayerConnected(player, serviceConnection.playAfterConnect);
|
||||
// reset the value of playAfterConnect: if it was true before, it is now "consumed"
|
||||
serviceConnection.playAfterConnect = false;
|
||||
player.setFragmentListener(internalListener);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package org.schabi.newpipe.player.helper
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.exoplayer2.PlaybackException
|
||||
import com.google.android.exoplayer2.PlaybackParameters
|
||||
import org.schabi.newpipe.App
|
||||
import org.schabi.newpipe.MainActivity
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo
|
||||
import org.schabi.newpipe.player.Player
|
||||
import org.schabi.newpipe.player.PlayerService
|
||||
import org.schabi.newpipe.player.PlayerService.LocalBinder
|
||||
import org.schabi.newpipe.player.PlayerType
|
||||
import org.schabi.newpipe.player.event.PlayerServiceEventListener
|
||||
import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue
|
||||
import org.schabi.newpipe.util.NavigationHelper
|
||||
|
||||
private val DEBUG = MainActivity.DEBUG
|
||||
private val TAG: String = PlayerHolder::class.java.getSimpleName()
|
||||
|
||||
/**
|
||||
* Singleton that manages a `PlayerService`
|
||||
* and can be used to control the player instance through the service.
|
||||
*/
|
||||
object PlayerHolder {
|
||||
private var listener: PlayerServiceExtendedEventListener? = null
|
||||
|
||||
var isBound: Boolean = false
|
||||
private set
|
||||
|
||||
private var playerService: PlayerService? = null
|
||||
|
||||
private val player: Player?
|
||||
get() = playerService?.player
|
||||
|
||||
// player play queue might be null e.g. while player is starting
|
||||
private val playQueue: PlayQueue?
|
||||
get() = this.player?.playQueue
|
||||
|
||||
val type: PlayerType?
|
||||
/**
|
||||
* Returns the current [PlayerType] of the [PlayerService] service,
|
||||
* otherwise `null` if no service is running.
|
||||
*
|
||||
* @return Current PlayerType
|
||||
*/
|
||||
get() = this.player?.playerType
|
||||
|
||||
val isPlaying: Boolean
|
||||
get() = this.player?.isPlaying == true
|
||||
|
||||
val isPlayerOpen: Boolean
|
||||
get() = this.player != null
|
||||
|
||||
val isPlayQueueReady: Boolean
|
||||
/**
|
||||
* Use this method to only allow the user to manipulate the play queue (e.g. by enqueueing via
|
||||
* the stream long press menu) when there actually is a play queue to manipulate.
|
||||
* @return true only if the player is open and its play queue is ready (i.e. it is not null)
|
||||
*/
|
||||
get() = this.playQueue != null
|
||||
|
||||
val queueSize: Int
|
||||
get() = this.playQueue?.size() ?: 0
|
||||
|
||||
val queuePosition: Int
|
||||
get() = this.playQueue?.index ?: 0
|
||||
|
||||
fun setListener(newListener: PlayerServiceExtendedEventListener?) {
|
||||
listener = newListener
|
||||
|
||||
// Force reload data from service
|
||||
newListener?.let { listener ->
|
||||
playerService?.let { service ->
|
||||
listener.onServiceConnected(service)
|
||||
startPlayerListener()
|
||||
// ^ will call listener.onPlayerConnected() down the line if there is an active player
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val commonContext: Context
|
||||
// helper to handle context in common place as using the same
|
||||
get() = App.instance
|
||||
|
||||
/**
|
||||
* Connect to (and if needed start) the [PlayerService]
|
||||
* and bind [PlayerServiceConnection] to it.
|
||||
* If the service is already started, only set the listener.
|
||||
* @param playAfterConnect If this holder’s service was already started,
|
||||
* start playing immediately
|
||||
* @param newListener set this listener
|
||||
*/
|
||||
fun startService(
|
||||
playAfterConnect: Boolean,
|
||||
newListener: PlayerServiceExtendedEventListener?
|
||||
) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "startService() called with playAfterConnect=$playAfterConnect")
|
||||
}
|
||||
val context = this.commonContext
|
||||
setListener(newListener)
|
||||
if (this.isBound) {
|
||||
return
|
||||
}
|
||||
// startService() can be called concurrently and it will give a random crashes
|
||||
// and NullPointerExceptions inside the service because the service will be
|
||||
// bound twice. Prevent it with unbinding first
|
||||
unbind(context)
|
||||
val intent = Intent(context, PlayerService::class.java)
|
||||
intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
PlayerServiceConnection.doPlayAfterConnect(playAfterConnect)
|
||||
bind(context)
|
||||
}
|
||||
|
||||
fun stopService() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "stopService() called")
|
||||
}
|
||||
playerService?.destroyPlayerAndStopService()
|
||||
val context = this.commonContext
|
||||
unbind(context)
|
||||
// destroyPlayerAndStopService() already runs the next line of code, but run it again just
|
||||
// to make sure to stop the service even if playerService is null by any chance.
|
||||
context.stopService(Intent(context, PlayerService::class.java))
|
||||
}
|
||||
|
||||
internal object PlayerServiceConnection : ServiceConnection {
|
||||
internal var playAfterConnect = false
|
||||
|
||||
/**
|
||||
* @param playAfterConnection Sets the value of [playAfterConnect] to pass to the
|
||||
* [PlayerServiceExtendedEventListener.onPlayerConnected] the next time it
|
||||
* is called. The value of [playAfterConnect] will be reset to false after that.
|
||||
*/
|
||||
fun doPlayAfterConnect(playAfterConnection: Boolean) {
|
||||
this.playAfterConnect = playAfterConnection
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(compName: ComponentName?) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Player service is disconnected")
|
||||
}
|
||||
|
||||
val context: Context = this@PlayerHolder.commonContext
|
||||
unbind(context)
|
||||
}
|
||||
|
||||
override fun onServiceConnected(compName: ComponentName?, service: IBinder?) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Player service is connected")
|
||||
}
|
||||
val localBinder = service as LocalBinder
|
||||
|
||||
val s = localBinder.service
|
||||
requireNotNull(s) {
|
||||
"PlayerService.LocalBinder.getService() must never be" +
|
||||
"null after the service connects"
|
||||
}
|
||||
playerService = s
|
||||
listener?.let { l ->
|
||||
l.onServiceConnected(s)
|
||||
player?.let { l.onPlayerConnected(it, playAfterConnect) }
|
||||
}
|
||||
startPlayerListener()
|
||||
// ^ will call listener.onPlayerConnected() down the line if there is an active player
|
||||
|
||||
if (playerService != null && playerService?.player != null) {
|
||||
// notify the main activity that binding the service has completed and that there is
|
||||
// a player, so that it can open the bottom mini-player
|
||||
NavigationHelper.sendPlayerStartedEvent(localBinder.service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bind(context: Context) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "bind() called")
|
||||
}
|
||||
// BIND_AUTO_CREATE starts the service if it's not already running
|
||||
this.isBound = bind(context, Context.BIND_AUTO_CREATE)
|
||||
if (!this.isBound) {
|
||||
context.unbindService(PlayerServiceConnection)
|
||||
}
|
||||
}
|
||||
|
||||
fun tryBindIfNeeded(context: Context) {
|
||||
if (!this.isBound) {
|
||||
// flags=0 means the service will not be started if it does not already exist. In this
|
||||
// case the return value is not useful, as a value of "true" does not really indicate
|
||||
// that the service is going to be bound.
|
||||
bind(context, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bind(context: Context, flags: Int): Boolean {
|
||||
val serviceIntent = Intent(context, PlayerService::class.java)
|
||||
serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION)
|
||||
return context.bindService(serviceIntent, PlayerServiceConnection, flags)
|
||||
}
|
||||
|
||||
private fun unbind(context: Context) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "unbind() called")
|
||||
}
|
||||
|
||||
if (this.isBound) {
|
||||
context.unbindService(PlayerServiceConnection)
|
||||
this.isBound = false
|
||||
stopPlayerListener()
|
||||
playerService = null
|
||||
listener?.onPlayerDisconnected()
|
||||
listener?.onServiceDisconnected()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPlayerListener() {
|
||||
// setting the player listener will take care of calling relevant callbacks if the
|
||||
// player in the service is (not) already active, also see playerStateListener below
|
||||
playerService?.setPlayerListener(playerStateListener)
|
||||
this.player?.setFragmentListener(HolderPlayerServiceEventListener)
|
||||
}
|
||||
|
||||
private fun stopPlayerListener() {
|
||||
playerService?.setPlayerListener(null)
|
||||
this.player?.removeFragmentListener(HolderPlayerServiceEventListener)
|
||||
}
|
||||
|
||||
/**
|
||||
* This listener will be held by the players created by [PlayerService].
|
||||
*/
|
||||
private object HolderPlayerServiceEventListener : PlayerServiceEventListener {
|
||||
override fun onViewCreated() {
|
||||
listener?.onViewCreated()
|
||||
}
|
||||
|
||||
override fun onFullscreenStateChanged(fullscreen: Boolean) {
|
||||
listener?.onFullscreenStateChanged(fullscreen)
|
||||
}
|
||||
|
||||
override fun onFullscreenToggleButtonClicked() {
|
||||
listener?.onFullscreenToggleButtonClicked()
|
||||
}
|
||||
|
||||
override fun onMoreOptionsLongClicked() {
|
||||
listener?.onMoreOptionsLongClicked()
|
||||
}
|
||||
|
||||
override fun onPlayerError(
|
||||
error: PlaybackException?,
|
||||
isCatchableException: Boolean
|
||||
) {
|
||||
listener?.onPlayerError(error, isCatchableException)
|
||||
}
|
||||
|
||||
override fun hideSystemUiIfNeeded() {
|
||||
listener?.hideSystemUiIfNeeded()
|
||||
}
|
||||
|
||||
override fun onQueueUpdate(queue: PlayQueue?) {
|
||||
listener?.onQueueUpdate(queue)
|
||||
}
|
||||
|
||||
override fun onPlaybackUpdate(
|
||||
state: Int,
|
||||
repeatMode: Int,
|
||||
shuffled: Boolean,
|
||||
parameters: PlaybackParameters?
|
||||
) {
|
||||
listener?.onPlaybackUpdate(state, repeatMode, shuffled, parameters)
|
||||
}
|
||||
|
||||
override fun onProgressUpdate(
|
||||
currentProgress: Int,
|
||||
duration: Int,
|
||||
bufferPercent: Int
|
||||
) {
|
||||
listener?.onProgressUpdate(currentProgress, duration, bufferPercent)
|
||||
}
|
||||
|
||||
override fun onMetadataUpdate(info: StreamInfo?, queue: PlayQueue?) {
|
||||
listener?.onMetadataUpdate(info, queue)
|
||||
}
|
||||
|
||||
override fun onServiceStopped() {
|
||||
listener?.onServiceStopped()
|
||||
unbind(this@PlayerHolder.commonContext)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This listener will be held by bound [PlayerService]s to notify of the player starting
|
||||
* or stopping. This is necessary since the service outlives the player e.g. to answer Android
|
||||
* Auto media browser queries.
|
||||
*/
|
||||
private val playerStateListener: (Player?) -> Unit = { player: Player? ->
|
||||
listener?.let { l ->
|
||||
if (player == null) {
|
||||
// player.fragmentListener=null is already done by player.stopActivityBinding(),
|
||||
// which is called by player.destroy(), which is in turn called by PlayerService
|
||||
// before setting its player to null
|
||||
l.onPlayerDisconnected()
|
||||
} else {
|
||||
l.onPlayerConnected(player, PlayerServiceConnection.playAfterConnect)
|
||||
// reset the value of playAfterConnect: if it was true before, it is now "consumed"
|
||||
PlayerServiceConnection.playAfterConnect = false
|
||||
player.setFragmentListener(HolderPlayerServiceEventListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.util.Log
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT
|
||||
import androidx.media.MediaBrowserServiceCompat.Result
|
||||
@@ -37,7 +38,6 @@ import org.schabi.newpipe.local.playlist.RemotePlaylistManager
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
import org.schabi.newpipe.util.ServiceHelper
|
||||
import org.schabi.newpipe.util.image.ImageStrategy
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
* This class is used to cleanly separate the Service implementation (in
|
||||
@@ -47,7 +47,7 @@ import java.util.function.Consumer
|
||||
*/
|
||||
class MediaBrowserImpl(
|
||||
private val context: Context,
|
||||
notifyChildrenChanged: Consumer<String>, // parentId
|
||||
notifyChildrenChanged: (parentId: String) -> Unit,
|
||||
) {
|
||||
private val packageValidator = PackageValidator(context)
|
||||
private val database = NewPipeDatabase.getInstance(context)
|
||||
@@ -55,9 +55,7 @@ class MediaBrowserImpl(
|
||||
|
||||
init {
|
||||
// this will listen to changes in the bookmarks until this MediaBrowserImpl is dispose()d
|
||||
disposables.add(
|
||||
getMergedPlaylists().subscribe { notifyChildrenChanged.accept(ID_BOOKMARKS) }
|
||||
)
|
||||
disposables.add(getMergedPlaylists().subscribe { notifyChildrenChanged(ID_BOOKMARKS) })
|
||||
}
|
||||
|
||||
//region Cleanup
|
||||
@@ -196,17 +194,16 @@ class MediaBrowserImpl(
|
||||
|
||||
private fun createPlaylistMediaItem(playlist: PlaylistLocalItem): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder
|
||||
.setMediaId(createMediaIdForInfoItem(playlist is PlaylistRemoteEntity, playlist.uid))
|
||||
.setTitle(playlist.orderingName)
|
||||
.setIconUri(imageUriOrNullIfDisabled(playlist.thumbnailUrl))
|
||||
.setExtras(
|
||||
bundleOf(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE
|
||||
to context.resources.getString(R.string.tab_bookmarks)
|
||||
)
|
||||
)
|
||||
|
||||
val extras = Bundle()
|
||||
extras.putString(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
|
||||
context.resources.getString(R.string.tab_bookmarks),
|
||||
)
|
||||
builder.setExtras(extras)
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE,
|
||||
@@ -215,8 +212,9 @@ class MediaBrowserImpl(
|
||||
|
||||
private fun createInfoItemMediaItem(item: InfoItem): MediaBrowserCompat.MediaItem? {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder.setMediaId(createMediaIdForInfoItem(item))
|
||||
.setMediaId(createMediaIdForInfoItem(item))
|
||||
.setTitle(item.name)
|
||||
.setIconUri(ImageStrategy.choosePreferredImage(item.thumbnails)?.toUri())
|
||||
|
||||
when (item.infoType) {
|
||||
InfoType.STREAM -> builder.setSubtitle((item as StreamInfoItem).uploaderName)
|
||||
@@ -225,10 +223,6 @@ class MediaBrowserImpl(
|
||||
else -> return null
|
||||
}
|
||||
|
||||
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
|
||||
builder.setIconUri(imageUriOrNullIfDisabled(it))
|
||||
}
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
@@ -269,7 +263,7 @@ class MediaBrowserImpl(
|
||||
index: Int,
|
||||
): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index))
|
||||
.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index))
|
||||
.setTitle(item.streamEntity.title)
|
||||
.setSubtitle(item.streamEntity.uploader)
|
||||
.setIconUri(imageUriOrNullIfDisabled(item.streamEntity.thumbnailUrl))
|
||||
@@ -289,10 +283,7 @@ class MediaBrowserImpl(
|
||||
builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index))
|
||||
.setTitle(item.name)
|
||||
.setSubtitle(item.uploaderName)
|
||||
|
||||
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
|
||||
builder.setIconUri(imageUriOrNullIfDisabled(it))
|
||||
}
|
||||
.setIconUri(ImageStrategy.choosePreferredImage(item.thumbnails)?.toUri())
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.PlaybackPreparer
|
||||
@@ -29,6 +28,7 @@ import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue
|
||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue
|
||||
import org.schabi.newpipe.util.ChannelTabHelper
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.NavigationHelper
|
||||
import java.util.function.BiConsumer
|
||||
import java.util.function.Consumer
|
||||
@@ -111,7 +111,7 @@ class MediaBrowserPlaybackPreparer(
|
||||
//region Errors
|
||||
private fun onUnsupportedError() {
|
||||
setMediaSessionError.accept(
|
||||
ContextCompat.getString(context, R.string.content_not_supported),
|
||||
Localization.compatGetString(context, R.string.content_not_supported),
|
||||
PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED
|
||||
)
|
||||
}
|
||||
|
||||
@@ -124,8 +124,10 @@ public class MediaSessionPlayerUi extends PlayerUi
|
||||
MediaButtonReceiver.handleIntent(mediaSession, intent);
|
||||
}
|
||||
|
||||
public Optional<MediaSessionCompat.Token> getSessionToken() {
|
||||
return Optional.ofNullable(mediaSession).map(MediaSessionCompat::getSessionToken);
|
||||
|
||||
@NonNull
|
||||
public MediaSessionCompat.Token getSessionToken() {
|
||||
return mediaSession.getSessionToken();
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +140,10 @@ public class MediaSessionPlayerUi extends PlayerUi
|
||||
public void play() {
|
||||
player.play();
|
||||
// hide the player controls even if the play command came from the media session
|
||||
player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0));
|
||||
final VideoPlayerUi ui = player.UIs().get(VideoPlayerUi.class);
|
||||
if (ui != null) {
|
||||
ui.hideControls(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -102,10 +102,10 @@ public final class NotificationUtil {
|
||||
final int[] compactSlots = initializeNotificationSlots();
|
||||
mediaStyle.setShowActionsInCompactView(compactSlots);
|
||||
}
|
||||
player.UIs()
|
||||
.get(MediaSessionPlayerUi.class)
|
||||
.flatMap(MediaSessionPlayerUi::getSessionToken)
|
||||
.ifPresent(mediaStyle::setMediaSession);
|
||||
@Nullable final MediaSessionPlayerUi ui = player.UIs().get(MediaSessionPlayerUi.class);
|
||||
if (ui != null) {
|
||||
mediaStyle.setMediaSession(ui.getSessionToken());
|
||||
}
|
||||
|
||||
// setup notification builder
|
||||
final var builder = setupNotificationBuilder(player.getContext(), mediaStyle)
|
||||
|
||||
@@ -38,9 +38,9 @@ import io.reactivex.rxjava3.internal.subscriptions.EmptySubscription;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject;
|
||||
|
||||
import static org.schabi.newpipe.BuildConfig.DEBUG;
|
||||
import static org.schabi.newpipe.player.mediasource.FailedMediaSource.MediaSourceResolutionException;
|
||||
import static org.schabi.newpipe.player.mediasource.FailedMediaSource.StreamInfoLoadException;
|
||||
import static org.schabi.newpipe.player.playqueue.PlayQueue.DEBUG;
|
||||
import static org.schabi.newpipe.util.ServiceHelper.getCacheExpirationMillis;
|
||||
|
||||
public class MediaSourceManager {
|
||||
|
||||
@@ -1,576 +0,0 @@
|
||||
package org.schabi.newpipe.player.playqueue;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.player.playqueue.events.AppendEvent;
|
||||
import org.schabi.newpipe.player.playqueue.events.ErrorEvent;
|
||||
import org.schabi.newpipe.player.playqueue.events.InitEvent;
|
||||
import org.schabi.newpipe.player.playqueue.events.MoveEvent;
|
||||
import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent;
|
||||
import org.schabi.newpipe.player.playqueue.events.RecoveryEvent;
|
||||
import org.schabi.newpipe.player.playqueue.events.RemoveEvent;
|
||||
import org.schabi.newpipe.player.playqueue.events.ReorderEvent;
|
||||
import org.schabi.newpipe.player.playqueue.events.SelectEvent;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.BackpressureStrategy;
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject;
|
||||
|
||||
/**
|
||||
* PlayQueue is responsible for keeping track of a list of streams and the index of
|
||||
* the stream that should be currently playing.
|
||||
* <p>
|
||||
* This class contains basic manipulation of a playlist while also functions as a
|
||||
* message bus, providing all listeners with new updates to the play queue.
|
||||
* </p>
|
||||
* <p>
|
||||
* This class can be serialized for passing intents, but in order to start the
|
||||
* message bus, it must be initialized.
|
||||
* </p>
|
||||
*/
|
||||
public abstract class PlayQueue implements Serializable {
|
||||
public static final boolean DEBUG = MainActivity.DEBUG;
|
||||
@NonNull
|
||||
private final AtomicInteger queueIndex;
|
||||
private final List<PlayQueueItem> history = new ArrayList<>();
|
||||
|
||||
private List<PlayQueueItem> backup;
|
||||
private List<PlayQueueItem> streams;
|
||||
|
||||
private transient BehaviorSubject<PlayQueueEvent> eventBroadcast;
|
||||
private transient Flowable<PlayQueueEvent> broadcastReceiver;
|
||||
private transient boolean disposed = false;
|
||||
|
||||
PlayQueue(final int index, final List<PlayQueueItem> startWith) {
|
||||
streams = new ArrayList<>(startWith);
|
||||
|
||||
if (streams.size() > index) {
|
||||
history.add(streams.get(index));
|
||||
}
|
||||
|
||||
queueIndex = new AtomicInteger(index);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Playlist actions
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
/**
|
||||
* Initializes the play queue message buses.
|
||||
* <p>
|
||||
* Also starts a self reporter for logging if debug mode is enabled.
|
||||
* </p>
|
||||
*/
|
||||
public void init() {
|
||||
eventBroadcast = BehaviorSubject.create();
|
||||
|
||||
broadcastReceiver = eventBroadcast.toFlowable(BackpressureStrategy.BUFFER)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.startWithItem(new InitEvent());
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the play queue by stopping all message buses.
|
||||
*/
|
||||
public void dispose() {
|
||||
if (eventBroadcast != null) {
|
||||
eventBroadcast.onComplete();
|
||||
}
|
||||
|
||||
eventBroadcast = null;
|
||||
broadcastReceiver = null;
|
||||
disposed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the queue is complete.
|
||||
* <p>
|
||||
* A queue is complete if it has loaded all items in an external playlist
|
||||
* single stream or local queues are always complete.
|
||||
* </p>
|
||||
*
|
||||
* @return whether the queue is complete
|
||||
*/
|
||||
public abstract boolean isComplete();
|
||||
|
||||
/**
|
||||
* Load partial queue in the background, does nothing if the queue is complete.
|
||||
*/
|
||||
public abstract void fetch();
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Readonly ops
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
/**
|
||||
* @return the current index that should be played
|
||||
*/
|
||||
public int getIndex() {
|
||||
return queueIndex.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the current playing index to a new index.
|
||||
* <p>
|
||||
* This method is guarded using in a circular manner for index exceeding the play queue size.
|
||||
* </p>
|
||||
* <p>
|
||||
* Will emit a {@link SelectEvent} if the index is not the current playing index.
|
||||
* </p>
|
||||
*
|
||||
* @param index the index to be set
|
||||
*/
|
||||
public synchronized void setIndex(final int index) {
|
||||
final int oldIndex = getIndex();
|
||||
|
||||
final int newIndex;
|
||||
|
||||
if (index < 0) {
|
||||
newIndex = 0;
|
||||
} else if (index < streams.size()) {
|
||||
// Regular assignment for index in bounds
|
||||
newIndex = index;
|
||||
} else if (streams.isEmpty()) {
|
||||
// Out of bounds from here on
|
||||
// Need to check if stream is empty to prevent arithmetic error and negative index
|
||||
newIndex = 0;
|
||||
} else if (isComplete()) {
|
||||
// Circular indexing
|
||||
newIndex = index % streams.size();
|
||||
} else {
|
||||
// Index of last element
|
||||
newIndex = streams.size() - 1;
|
||||
}
|
||||
|
||||
queueIndex.set(newIndex);
|
||||
|
||||
if (oldIndex != newIndex) {
|
||||
history.add(streams.get(newIndex));
|
||||
}
|
||||
|
||||
/*
|
||||
TODO: Documentation states that a SelectEvent will only be emitted if the new index is...
|
||||
different from the old one but this is emitted regardless? Not sure what this what it does
|
||||
exactly so I won't touch it
|
||||
*/
|
||||
broadcast(new SelectEvent(oldIndex, newIndex));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the current item that should be played, or null if the queue is empty
|
||||
*/
|
||||
@Nullable
|
||||
public PlayQueueItem getItem() {
|
||||
return getItem(getIndex());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param index the index of the item to return
|
||||
* @return the item at the given index, or null if the index is out of bounds
|
||||
*/
|
||||
@Nullable
|
||||
public PlayQueueItem getItem(final int index) {
|
||||
if (index < 0 || index >= streams.size()) {
|
||||
return null;
|
||||
}
|
||||
return streams.get(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the given item using referential equality.
|
||||
* May be null despite play queue contains identical item.
|
||||
*
|
||||
* @param item the item to find the index of
|
||||
* @return the index of the given item
|
||||
*/
|
||||
public int indexOf(@NonNull final PlayQueueItem item) {
|
||||
return streams.indexOf(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the current size of play queue.
|
||||
*/
|
||||
public int size() {
|
||||
return streams.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the play queue is empty.
|
||||
*
|
||||
* @return whether the play queue is empty
|
||||
*/
|
||||
public boolean isEmpty() {
|
||||
return streams.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the current play queue is shuffled.
|
||||
*
|
||||
* @return whether the play queue is shuffled
|
||||
*/
|
||||
public boolean isShuffled() {
|
||||
return backup != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return an immutable view of the play queue
|
||||
*/
|
||||
@NonNull
|
||||
public List<PlayQueueItem> getStreams() {
|
||||
return Collections.unmodifiableList(streams);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Write ops
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
/**
|
||||
* Returns the play queue's update broadcast.
|
||||
* May be null if the play queue message bus is not initialized.
|
||||
*
|
||||
* @return the play queue's update broadcast
|
||||
*/
|
||||
@Nullable
|
||||
public Flowable<PlayQueueEvent> getBroadcastReceiver() {
|
||||
return broadcastReceiver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the current playing index by an offset amount.
|
||||
* <p>
|
||||
* Will emit a {@link SelectEvent} if offset is non-zero.
|
||||
* </p>
|
||||
*
|
||||
* @param offset the offset relative to the current index
|
||||
*/
|
||||
public synchronized void offsetIndex(final int offset) {
|
||||
setIndex(getIndex() + offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies that a change has occurred.
|
||||
*/
|
||||
public synchronized void notifyChange() {
|
||||
broadcast(new AppendEvent(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the given {@link PlayQueueItem}s to the current play queue.
|
||||
* <p>
|
||||
* If the play queue is shuffled, then append the items to the backup queue as is and
|
||||
* append the shuffle items to the play queue.
|
||||
* </p>
|
||||
* <p>
|
||||
* Will emit a {@link AppendEvent} on any given context.
|
||||
* </p>
|
||||
*
|
||||
* @param items {@link PlayQueueItem}s to append
|
||||
*/
|
||||
public synchronized void append(@NonNull final List<PlayQueueItem> items) {
|
||||
final List<PlayQueueItem> itemList = new ArrayList<>(items);
|
||||
|
||||
if (isShuffled()) {
|
||||
backup.addAll(itemList);
|
||||
Collections.shuffle(itemList);
|
||||
}
|
||||
if (!streams.isEmpty() && streams.get(streams.size() - 1).isAutoQueued()
|
||||
&& !itemList.get(0).isAutoQueued()) {
|
||||
streams.remove(streams.size() - 1);
|
||||
}
|
||||
streams.addAll(itemList);
|
||||
|
||||
broadcast(new AppendEvent(itemList.size()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the given item after the current stream.
|
||||
*
|
||||
* @param item item to add.
|
||||
* @param skipIfSame if set, skip adding if the next stream is the same stream.
|
||||
*/
|
||||
public void enqueueNext(@NonNull final PlayQueueItem item, final boolean skipIfSame) {
|
||||
final int currentIndex = getIndex();
|
||||
// if the next item is the same item as the one we want to enqueue, skip if flag is true
|
||||
if (skipIfSame && item.isSameItem(getItem(currentIndex + 1))) {
|
||||
return;
|
||||
}
|
||||
append(List.of(item));
|
||||
move(size() - 1, currentIndex + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the item at the given index from the play queue.
|
||||
* <p>
|
||||
* The current playing index will decrement if it is greater than the index being removed.
|
||||
* On cases where the current playing index exceeds the playlist range, it is set to 0.
|
||||
* </p>
|
||||
* <p>
|
||||
* Will emit a {@link RemoveEvent} if the index is within the play queue index range.
|
||||
* </p>
|
||||
*
|
||||
* @param index the index of the item to remove
|
||||
*/
|
||||
public synchronized void remove(final int index) {
|
||||
if (index >= streams.size() || index < 0) {
|
||||
return;
|
||||
}
|
||||
removeInternal(index);
|
||||
broadcast(new RemoveEvent(index, getIndex()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an exception for the item at the current index in order and skip to the next one
|
||||
* <p>
|
||||
* This is done as a separate event as the underlying manager may have
|
||||
* different implementation regarding exceptions.
|
||||
* </p>
|
||||
*/
|
||||
public synchronized void error() {
|
||||
final int oldIndex = getIndex();
|
||||
queueIndex.incrementAndGet();
|
||||
if (streams.size() > queueIndex.get()) {
|
||||
history.add(streams.get(queueIndex.get()));
|
||||
}
|
||||
broadcast(new ErrorEvent(oldIndex, getIndex()));
|
||||
}
|
||||
|
||||
private synchronized void removeInternal(final int removeIndex) {
|
||||
final int currentIndex = queueIndex.get();
|
||||
final int size = size();
|
||||
|
||||
if (currentIndex > removeIndex) {
|
||||
queueIndex.decrementAndGet();
|
||||
|
||||
} else if (currentIndex >= size) {
|
||||
queueIndex.set(currentIndex % (size - 1));
|
||||
|
||||
} else if (currentIndex == removeIndex && currentIndex == size - 1) {
|
||||
queueIndex.set(0);
|
||||
}
|
||||
|
||||
if (backup != null) {
|
||||
backup.remove(getItem(removeIndex));
|
||||
}
|
||||
|
||||
history.remove(streams.remove(removeIndex));
|
||||
if (streams.size() > queueIndex.get()) {
|
||||
history.add(streams.get(queueIndex.get()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a queue item at the source index to the target index.
|
||||
* <p>
|
||||
* If the item being moved is the currently playing, then the current playing index is set
|
||||
* to that of the target.
|
||||
* If the moved item is not the currently playing and moves to an index <b>AFTER</b> the
|
||||
* current playing index, then the current playing index is decremented.
|
||||
* Vice versa if the an item after the currently playing is moved <b>BEFORE</b>.
|
||||
* </p>
|
||||
*
|
||||
* @param source the original index of the item
|
||||
* @param target the new index of the item
|
||||
*/
|
||||
public synchronized void move(final int source, final int target) {
|
||||
if (source < 0 || target < 0) {
|
||||
return;
|
||||
}
|
||||
if (source >= streams.size() || target >= streams.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final int current = getIndex();
|
||||
if (source == current) {
|
||||
queueIndex.set(target);
|
||||
} else if (source < current && target >= current) {
|
||||
queueIndex.decrementAndGet();
|
||||
} else if (source > current && target <= current) {
|
||||
queueIndex.incrementAndGet();
|
||||
}
|
||||
|
||||
final PlayQueueItem playQueueItem = streams.remove(source);
|
||||
playQueueItem.setAutoQueued(false);
|
||||
streams.add(target, playQueueItem);
|
||||
broadcast(new MoveEvent(source, target));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the recovery record of the item at the index.
|
||||
* <p>
|
||||
* Broadcasts a recovery event.
|
||||
* </p>
|
||||
*
|
||||
* @param index index of the item
|
||||
* @param position the recovery position
|
||||
*/
|
||||
public synchronized void setRecovery(final int index, final long position) {
|
||||
if (index < 0 || index >= streams.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
streams.get(index).setRecoveryPosition(position);
|
||||
broadcast(new RecoveryEvent(index, position));
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the recovery record of the item at the index.
|
||||
* <p>
|
||||
* Broadcasts a recovery event.
|
||||
* </p>
|
||||
*
|
||||
* @param index index of the item
|
||||
*/
|
||||
public synchronized void unsetRecovery(final int index) {
|
||||
setRecovery(index, PlayQueueItem.RECOVERY_UNSET);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffles the current play queue
|
||||
* <p>
|
||||
* This method first backs up the existing play queue and item being played. Then a newly
|
||||
* shuffled play queue will be generated along with currently playing item placed at the
|
||||
* beginning of the queue. This item will also be added to the history.
|
||||
* </p>
|
||||
* <p>
|
||||
* Will emit a {@link ReorderEvent} if shuffled.
|
||||
* </p>
|
||||
*
|
||||
* @implNote Does nothing if the queue has a size <= 2 (the currently playing video must stay on
|
||||
* top, so shuffling a size-2 list does nothing)
|
||||
*/
|
||||
public synchronized void shuffle() {
|
||||
// Create a backup if it doesn't already exist
|
||||
// Note: The backup-list has to be created at all cost (even when size <= 2).
|
||||
// Otherwise it's not possible to enter shuffle-mode!
|
||||
if (backup == null) {
|
||||
backup = new ArrayList<>(streams);
|
||||
}
|
||||
// Can't shuffle a list that's empty or only has one element
|
||||
if (size() <= 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
final int originalIndex = getIndex();
|
||||
final PlayQueueItem currentItem = getItem();
|
||||
|
||||
Collections.shuffle(streams);
|
||||
|
||||
// Move currentItem to the head of the queue
|
||||
streams.remove(currentItem);
|
||||
streams.add(0, currentItem);
|
||||
queueIndex.set(0);
|
||||
|
||||
history.add(currentItem);
|
||||
|
||||
broadcast(new ReorderEvent(originalIndex, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unshuffles the current play queue if a backup play queue exists.
|
||||
* <p>
|
||||
* This method undoes shuffling and index will be set to the previously playing item if found,
|
||||
* otherwise, the index will reset to 0.
|
||||
* </p>
|
||||
* <p>
|
||||
* Will emit a {@link ReorderEvent} if a backup exists.
|
||||
* </p>
|
||||
*/
|
||||
public synchronized void unshuffle() {
|
||||
if (backup == null) {
|
||||
return;
|
||||
}
|
||||
final int originIndex = getIndex();
|
||||
final PlayQueueItem current = getItem();
|
||||
|
||||
streams = backup;
|
||||
backup = null;
|
||||
|
||||
final int newIndex = streams.indexOf(current);
|
||||
if (newIndex != -1) {
|
||||
queueIndex.set(newIndex);
|
||||
} else {
|
||||
queueIndex.set(0);
|
||||
}
|
||||
if (streams.size() > queueIndex.get()) {
|
||||
history.add(streams.get(queueIndex.get()));
|
||||
}
|
||||
|
||||
broadcast(new ReorderEvent(originIndex, queueIndex.get()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects previous played item.
|
||||
*
|
||||
* This method removes currently playing item from history and
|
||||
* starts playing the last item from history if it exists
|
||||
*
|
||||
* @return true if history is not empty and the item can be played
|
||||
* */
|
||||
public synchronized boolean previous() {
|
||||
if (history.size() <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
history.remove(history.size() - 1);
|
||||
|
||||
final PlayQueueItem last = history.remove(history.size() - 1);
|
||||
setIndex(indexOf(last));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
* Compares two PlayQueues. Useful when a user switches players but queue is the same so
|
||||
* we don't have to do anything with new queue.
|
||||
* This method also gives a chance to track history of items in a queue in
|
||||
* VideoDetailFragment without duplicating items from two identical queues
|
||||
*/
|
||||
public boolean equalStreams(@Nullable final PlayQueue other) {
|
||||
if (other == null) {
|
||||
return false;
|
||||
}
|
||||
if (size() != other.size()) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < size(); i++) {
|
||||
final PlayQueueItem stream = streams.get(i);
|
||||
final PlayQueueItem otherStream = other.streams.get(i);
|
||||
// Check is based on serviceId and URL
|
||||
if (!stream.isSameItem(otherStream)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean equalStreamsAndIndex(@Nullable final PlayQueue other) {
|
||||
if (equalStreams(other)) {
|
||||
//noinspection ConstantConditions
|
||||
return other.getIndex() == getIndex(); //NOSONAR: other is not null
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isDisposed() {
|
||||
return disposed;
|
||||
}
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Rx Broadcast
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private void broadcast(@NonNull final PlayQueueEvent event) {
|
||||
if (eventBroadcast != null) {
|
||||
eventBroadcast.onNext(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user