mirror of
https://github.com/TeamNewPipe/NewPipe
synced 2026-09-23 17:38:51 +00:00
Compare commits
444
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52886c235a | ||
|
|
80d86b2b51 | ||
|
|
6f7276759b | ||
|
|
8065b6cc3f | ||
|
|
271edd826e | ||
|
|
3a3eac0cf2 | ||
|
|
fe084598ba | ||
|
|
9ddfb81bf4 | ||
|
|
01c565e4f7 | ||
|
|
079f5f1d70 | ||
|
|
0334074931 | ||
|
|
c24cc85743 | ||
|
|
84142c1468 | ||
|
|
2bbd439e9a | ||
|
|
a44e65e709 | ||
|
|
188ba0a462 | ||
|
|
7b98b4058a | ||
|
|
78f9fbadb7 | ||
|
|
2ebb0dc114 | ||
|
|
9bd9a5c6e9 | ||
|
|
ee25b44170 | ||
|
|
43f40c485d | ||
|
|
c992b85b25 | ||
|
|
49354a3c04 | ||
|
|
bfefd43700 | ||
|
|
dd917ca240 | ||
|
|
ae03b4f2af | ||
|
|
53d8d4d4da | ||
|
|
064458cccd | ||
|
|
44bf345ba9 | ||
|
|
0e2e94dcb9 | ||
|
|
67b6f0a10b | ||
|
|
274cb7c170 | ||
|
|
62e66326a5 | ||
|
|
25c12ef81e | ||
|
|
d33ba8ac75 | ||
|
|
fbfa1fd2b1 | ||
|
|
881792ebda | ||
|
|
de344100db | ||
|
|
5fafccba33 | ||
|
|
5e9c24a7b3 | ||
|
|
c85655f5bb | ||
|
|
dfe12222ff | ||
|
|
d0a0b4a541 | ||
|
|
00208fa8bd | ||
|
|
e0a69e8840 | ||
|
|
4c82e8bafd | ||
|
|
064e53221e | ||
|
|
8b28bd1a18 | ||
|
|
b6c50ccca5 | ||
|
|
3a42827320 | ||
|
|
dd651ecc45 | ||
|
|
941edd7aef | ||
|
|
c2b698491b | ||
|
|
aa8aa6a241 | ||
|
|
a430517189 | ||
|
|
e006328bb1 | ||
|
|
d859a5edc8 | ||
|
|
f63ea4aa93 | ||
|
|
abf9a80715 | ||
|
|
60615e6b9e | ||
|
|
51a749a7f1 | ||
|
|
35315d02ef | ||
|
|
50b9a7b7f6 | ||
|
|
4b424bc6bd | ||
|
|
2376a83e0c | ||
|
|
394a7f68cd | ||
|
|
61c25d4589 | ||
|
|
b84395dea0 | ||
|
|
0b43dd2a62 | ||
|
|
f5245eac91 | ||
|
|
3369607b74 | ||
|
|
2e8e203276 | ||
|
|
127064d6fc | ||
|
|
cd056a7f7f | ||
|
|
040b4c44ca | ||
|
|
8ae5a55c4f | ||
|
|
8b038ef1c8 | ||
|
|
4360c1b873 | ||
|
|
4649e46d1e | ||
|
|
8e32e7a4b4 | ||
|
|
c596476c06 | ||
|
|
90c36cb2e8 | ||
|
|
01dee9dd13 | ||
|
|
bbcf57f93a | ||
|
|
0fc2d4c948 | ||
|
|
5e88ac4cd8 | ||
|
|
1bca5f3d63 | ||
|
|
40caeb465d | ||
|
|
125fc38ffe | ||
|
|
6e0b7be15c | ||
|
|
4604339583 | ||
|
|
25e96d584a | ||
|
|
9d3ac1b94f | ||
|
|
d9ddc07d4b | ||
|
|
8856e97c62 | ||
|
|
aed4278388 | ||
|
|
3ab1322425 | ||
|
|
fab0d35269 | ||
|
|
5ba95a2c37 | ||
|
|
2d3a4b67d7 | ||
|
|
9cbcaecb92 | ||
|
|
848b8868fb | ||
|
|
f9222a6a68 | ||
|
|
8292f729ea | ||
|
|
19fb9899cd | ||
|
|
da4878d264 | ||
|
|
abfde872f1 | ||
|
|
c98f56bf7b | ||
|
|
759342fa62 | ||
|
|
0382cfd2ae | ||
|
|
753a3e68d5 | ||
|
|
d7953d620c | ||
|
|
b6bd87a4dc | ||
|
|
b36201442d | ||
|
|
9d3775f132 | ||
|
|
b2d89a41fb | ||
|
|
01a8c4e584 | ||
|
|
2ee7cc4344 | ||
|
|
2cb465c89d | ||
|
|
ccca89dc8a | ||
|
|
9bf23abcd1 | ||
|
|
44538cea30 | ||
|
|
10c5a5d02c | ||
|
|
cf4b5e17c5 | ||
|
|
deb5425871 | ||
|
|
48d0ebff9b | ||
|
|
2915ab6aef | ||
|
|
be662a9f1a | ||
|
|
1534c880ef | ||
|
|
ded7205588 | ||
|
|
1f2e5799f7 | ||
|
|
f93806293a | ||
|
|
fa75283d48 | ||
|
|
da36b8a140 | ||
|
|
046ea7301b | ||
|
|
3998982002 | ||
|
|
a4182b474b | ||
|
|
317db719db | ||
|
|
3f62ec7e53 | ||
|
|
7330541499 | ||
|
|
ed0051a3f6 | ||
|
|
91aed1e240 | ||
|
|
38ed1da79e | ||
|
|
cc3ecd4169 | ||
|
|
bf72fd1fa5 | ||
|
|
86b27cf77d | ||
|
|
4fd3ddf392 | ||
|
|
fc7daa96e9 | ||
|
|
9b22773070 | ||
|
|
86063fda6a | ||
|
|
c9be4066f2 | ||
|
|
462f0ad5c0 | ||
|
|
8b2c61432e | ||
|
|
eac0dcd70d | ||
|
|
f89ee3b376 | ||
|
|
b0427ec0d0 | ||
|
|
183cc580fc | ||
|
|
4eae345057 | ||
|
|
52ff052d6a | ||
|
|
0e422a42ee | ||
|
|
31f8dd05a7 | ||
|
|
c2b6c71947 | ||
|
|
bb7873d157 | ||
|
|
e2a02a1f86 | ||
|
|
690af88db9 | ||
|
|
4ffadc2057 | ||
|
|
225cb91105 | ||
|
|
3e106b5e4f | ||
|
|
72b67ab5d4 | ||
|
|
840084d2c9 | ||
|
|
2acfefd0bc | ||
|
|
d81b571d05 | ||
|
|
6efb92a38f | ||
|
|
12a97c6439 | ||
|
|
f16becc872 | ||
|
|
60ea317e61 | ||
|
|
e53f0ff94a | ||
|
|
006b4c9ae1 | ||
|
|
73fef268fc | ||
|
|
cf8fe95abf | ||
|
|
36115c3164 | ||
|
|
be373dca8d | ||
|
|
f5a4af2d67 | ||
|
|
06cf511188 | ||
|
|
26050d808e | ||
|
|
0b32738d42 | ||
|
|
c37db85b97 | ||
|
|
4d6e1a4ecf | ||
|
|
b5dd49ecd3 | ||
|
|
945fbd884b | ||
|
|
545c4f078f | ||
|
|
d4cd54fd7b | ||
|
|
15ab3df511 | ||
|
|
f41b34c090 | ||
|
|
eb6a03aa1e | ||
|
|
d1954baf29 | ||
|
|
a8da9946d1 | ||
|
|
3d069cdf5b | ||
|
|
eccedc0ab0 | ||
|
|
7cecda5713 | ||
|
|
b6144d01f3 | ||
|
|
df11e53a74 | ||
|
|
1d94fd1582 | ||
|
|
c9542ad6fd | ||
|
|
cbe598182a | ||
|
|
27a29aefc2 | ||
|
|
7615f79aca | ||
|
|
451c89b8e2 | ||
|
|
47299c9184 | ||
|
|
6486f2de56 | ||
|
|
287db90f62 | ||
|
|
6f9af2638b | ||
|
|
fe563f3307 | ||
|
|
e00e2562fb | ||
|
|
a58b0da2c1 | ||
|
|
54c2b499af | ||
|
|
2bafa2c715 | ||
|
|
033d288077 | ||
|
|
851ba4bf1d | ||
|
|
975ba3c067 | ||
|
|
d805679a5e | ||
|
|
e877997603 | ||
|
|
c1bdffd917 | ||
|
|
abaf16e12b | ||
|
|
491b433f32 | ||
|
|
dbe30116e3 | ||
|
|
99aae7eb28 | ||
|
|
695c05bd8e | ||
|
|
ac34ada7c9 | ||
|
|
212c67838a | ||
|
|
61bb81f2d6 | ||
|
|
6de5ce77c8 | ||
|
|
fd99c5e461 | ||
|
|
407d2d768d | ||
|
|
b109e4d3cc | ||
|
|
137ade24ff | ||
|
|
5abc03c4cf | ||
|
|
48d682016e | ||
|
|
21caa6ce13 | ||
|
|
b78e0b2da8 | ||
|
|
32a88ab890 | ||
|
|
af3ed992e5 | ||
|
|
a79516dfff | ||
|
|
59a748d0ba | ||
|
|
dbd11a6a8d | ||
|
|
21973b362a | ||
|
|
60586c90d6 | ||
|
|
095155d35f | ||
|
|
af8e5646a6 | ||
|
|
c9d155a335 | ||
|
|
4e31ccebf8 | ||
|
|
c0965a42a1 | ||
|
|
fc7f1b0af0 | ||
|
|
dfb035dfa5 | ||
|
|
8e9503cfe4 | ||
|
|
82516dd75c | ||
|
|
3e6e980362 | ||
|
|
1890fbb19a | ||
|
|
bf0692377c | ||
|
|
3943a87966 | ||
|
|
aeb4548a5a | ||
|
|
6621b7feaa | ||
|
|
fa98a92415 | ||
|
|
38645b02df | ||
|
|
48ad12375b | ||
|
|
3f1bd58751 | ||
|
|
38a533f9a8 | ||
|
|
4ec5492814 | ||
|
|
f99fc13326 | ||
|
|
3d9394e69b | ||
|
|
77e254b2be | ||
|
|
169eff809e | ||
|
|
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 |
@@ -57,7 +57,7 @@ You'll see *exactly* what is sent, be able to add **your comments**, and then se
|
||||
|
||||
### Creating a Pull Request (PR)
|
||||
|
||||
* Make changes on a **separate branch** with a meaningful name, not on the _master_ branch or the _dev_ branch. This is commonly known as *feature branch workflow*. You may then send your changes as a pull request (PR) on GitHub against the `dev` branch.
|
||||
* Make changes on a **separate branch** with a meaningful name, not on the _master_ branch or the _dev_ branch. This is commonly known as *feature branch workflow*. You may then send your changes as a pull request (PR) on GitHub.
|
||||
* Please **test** (compile and run) your code before submitting changes! Ideally, provide test feedback in the PR description. Untested code will **not** be merged!
|
||||
* Respond if someone requests changes or otherwise raises issues about your PRs.
|
||||
* Try to figure out yourself why builds on our CI fail.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -24,16 +24,8 @@
|
||||
#### Relies on the following changes
|
||||
<!-- Delete this if it doesn't apply to your PR. -->
|
||||
-
|
||||
#### Testing/QA
|
||||
|
||||
##### Devices
|
||||
- tested on device/emulator(phone/tablet/TV/...) running Android XX / API level by doing ...
|
||||
|
||||
##### Steps for testing
|
||||
<!-- Explain which steps you made to test that your changes fix the bug or implement the feature correctly and do not introduce regressions. This is mandatory to ensure that reviewers can quickly identify if you missed a crucial step. Link unit or integration tests here, if created or changed. --->
|
||||
-
|
||||
|
||||
##### APK for testing
|
||||
#### APK testing
|
||||
<!-- Use a new, meaningfully named branch. The name is used as a suffix for the app ID to allow installing and testing multiple versions of NewPipe, e.g. "commentfix", if your PR implements a bugfix for comments. (No names like "patch-0" and "feature-1".) -->
|
||||
<!-- Remove the following line if you directly link the APK created by the CI pipeline. Directly linking is preferred if you need to let users test.-->
|
||||
The APK can be found by going to the "Checks" tab below the title. On the left pane, click on "CI", scroll down to "artifacts" and click "app" to download the zip file which contains the debug APK of this PR. You can find more info and a video demonstration [on this wiki page](https://github.com/TeamNewPipe/NewPipe/wiki/Download-APK-for-PR).
|
||||
|
||||
@@ -12,6 +12,7 @@ captures/
|
||||
*.class
|
||||
app/debug/
|
||||
app/release/
|
||||
.kotlin/
|
||||
|
||||
# vscode / eclipse files
|
||||
*.classpath
|
||||
@@ -32,3 +33,6 @@ node_modules/
|
||||
!*.xcodeproj/project.xcworkspace/
|
||||
!*.xcworkspace/contents.xcworkspacedata
|
||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||
|
||||
# logs
|
||||
*.log
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
<h3 align="center">We are <i>rewriting</i> large chunks of the codebase, to bring about <a href="https://newpipe.net/blog/pinned/announcement/newpipe-0.27.6-rewrite-team-states/#the-refactor">a modern and stable NewPipe</a>! You can download nightly builds <a href="https://github.com/TeamNewPipe/NewPipe-refactor-nightly/releases">here</a>.</h3>
|
||||
<h4 align="center">Please work on the <code>refactor</code> branch if you want to contribute <i>new features</i>. The current codebase is in maintenance mode and will only receive <i>bugfixes</i>.</h4>
|
||||
|
||||
<p align="center"><a href="https://newpipe.net"><img src="assets/new_pipe_icon_5.png" width="150"></a></p>
|
||||
<h2 align="center"><b>NewPipe</b></h2>
|
||||
<h4 align="center">A libre lightweight streaming front-end for Android.</h4>
|
||||
@@ -7,6 +10,7 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe/releases" alt="GitHub NewPipe releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe.svg" ></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe-nightly/releases" alt="GitHub NewPipe nightly releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe-nightly.svg?labelColor=purple&label=dev%20nightly"></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe-refactor-nightly/releases" alt="GitHub NewPipe refactor nightly releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe-refactor-nightly.svg?labelColor=purple&label=refactor%20nightly"></a>
|
||||
<a href="https://www.gnu.org/licenses/gpl-3.0" alt="License: GPLv3"><img src="https://img.shields.io/badge/License-GPL%20v3-blue.svg"></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe/actions" alt="Build Status"><img src="https://github.com/TeamNewPipe/NewPipe/actions/workflows/ci.yml/badge.svg?branch=dev&event=push"></a>
|
||||
<a href="https://hosted.weblate.org/engage/newpipe/" alt="Translation Status"><img src="https://hosted.weblate.org/widgets/newpipe/-/svg-badge.svg"></a>
|
||||
|
||||
+62
-9
@@ -2,17 +2,19 @@
|
||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import com.android.build.api.dsl.ApplicationExtension
|
||||
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
|
||||
import java.util.regex.Pattern
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.android.legacy.kapt)
|
||||
alias(libs.plugins.google.ksp)
|
||||
alias(libs.plugins.jetbrains.kotlin.compose)
|
||||
alias(libs.plugins.jetbrains.kotlin.parcelize)
|
||||
alias(libs.plugins.jetbrains.kotlinx.serialization)
|
||||
alias(libs.plugins.sonarqube)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.about.libraries)
|
||||
checkstyle
|
||||
}
|
||||
@@ -28,6 +30,12 @@ val normalizedWorkingBranch = workingBranch
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
compilerOptions {
|
||||
// TODO: Drop annotation default target when it is stable
|
||||
freeCompilerArgs.addAll(
|
||||
"-Xannotation-default-target=param-property"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
configure<ApplicationExtension> {
|
||||
@@ -123,6 +131,7 @@ configure<ApplicationExtension> {
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
compose = true
|
||||
buildConfig = true
|
||||
resValues = true
|
||||
}
|
||||
@@ -215,6 +224,12 @@ sonar {
|
||||
}
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
// note: offline mode prevents the plugin from fetching licenses at build time, which would be
|
||||
// harmful for reproducible builds
|
||||
offlineMode = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Desugaring
|
||||
coreLibraryDesugaring(libs.android.desugar)
|
||||
@@ -229,32 +244,64 @@ dependencies {
|
||||
checkstyle(libs.puppycrawl.checkstyle)
|
||||
ktlint(libs.pinterest.ktlint)
|
||||
|
||||
// AndroidX
|
||||
/** 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)
|
||||
implementation(libs.androidx.room.paging)
|
||||
implementation(libs.androidx.room.runtime)
|
||||
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 navigatio3
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.androidx.navigation3.viewmodel)
|
||||
|
||||
// Jetpack Compose related dependencies
|
||||
implementation(libs.androidx.paging.compose)
|
||||
implementation(libs.androidx.hilt.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)
|
||||
|
||||
@@ -316,7 +363,10 @@ dependencies {
|
||||
debugImplementation(libs.facebook.stetho.core)
|
||||
debugImplementation(libs.facebook.stetho.okhttp3)
|
||||
|
||||
// Testing
|
||||
// Jetpack Compose
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
/** Testing **/
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockito.core)
|
||||
|
||||
@@ -324,6 +374,9 @@ 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)
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
|
||||
Vendored
+15
@@ -35,6 +35,21 @@
|
||||
## 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(...);
|
||||
}
|
||||
|
||||
# Prevent R8 from stripping or renaming Protobuf internal fields
|
||||
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
|
||||
<fields>;
|
||||
|
||||
@@ -6,6 +6,9 @@ import androidx.test.core.app.ApplicationProvider
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import java.io.IOException
|
||||
import java.time.OffsetDateTime
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
@@ -106,14 +109,10 @@ class FeedDAOTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupUnlinkDelete(time: String) {
|
||||
private fun setupUnlinkDelete(time: String) = runBlocking(Dispatchers.IO) {
|
||||
clearAndFillTables()
|
||||
Single.fromCallable {
|
||||
feedDAO.unlinkStreamsOlderThan(OffsetDateTime.parse(time))
|
||||
}.blockingSubscribe()
|
||||
Single.fromCallable {
|
||||
streamDAO.deleteOrphans()
|
||||
}.blockingSubscribe()
|
||||
feedDAO.unlinkStreamsOlderThan(OffsetDateTime.parse(time))
|
||||
streamDAO.deleteOrphans().await()
|
||||
}
|
||||
|
||||
private fun clearAndFillTables() {
|
||||
|
||||
@@ -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,127 @@
|
||||
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 java.io.IOException
|
||||
import java.net.SocketTimeoutException
|
||||
import org.junit.Assert.assertEquals
|
||||
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
|
||||
|
||||
/**
|
||||
* Instrumented tests for {@link ErrorInfo}.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@LargeTest
|
||||
class ErrorInfoTest {
|
||||
private val context: Context by lazy { ApplicationProvider.getApplicationContext() }
|
||||
|
||||
/**
|
||||
* @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)
|
||||
assertTrue(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 java.net.UnknownHostException
|
||||
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.ReCaptchaException
|
||||
import org.schabi.newpipe.extractor.exceptions.UnsupportedContentInCountryException
|
||||
import org.schabi.newpipe.ui.theme.AppTheme
|
||||
|
||||
@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 all buttons: solve, retry, open in browser, report
|
||||
*/
|
||||
@Test
|
||||
fun recaptchaErrorShowsAllButtons() {
|
||||
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)
|
||||
.assertIsDisplayed()
|
||||
assert(retryClicked) { "onRetry callback should have been invoked" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Content Not Available Error hides retry button
|
||||
*/
|
||||
@Test
|
||||
fun testNonRetryableErrorHidesRetryAndReportButtons() {
|
||||
val contentNotAvailable = ErrorInfo(
|
||||
throwable = UnsupportedContentInCountryException("Not available here"),
|
||||
userAction = UserAction.REQUESTED_STREAM,
|
||||
request = "https://example.com/watch?v=qux"
|
||||
)
|
||||
|
||||
setErrorPanel(contentNotAvailable)
|
||||
|
||||
composeRule.onNodeWithText(text(R.string.unsupported_content_in_country))
|
||||
.assertIsDisplayed()
|
||||
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true)
|
||||
.assertDoesNotExist()
|
||||
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
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 java.net.UnknownHostException
|
||||
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
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package org.schabi.newpipe.settings;
|
||||
|
||||
import android.content.Intent;
|
||||
|
||||
import leakcanary.LeakCanary;
|
||||
|
||||
/**
|
||||
* Build variant dependent (BVD) leak canary API implementation for the debug settings fragment.
|
||||
* This class is loaded via reflection by
|
||||
* {@link DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI}.
|
||||
*/
|
||||
@SuppressWarnings("unused") // Class is used but loaded via reflection
|
||||
public class DebugSettingsBVDLeakCanary
|
||||
implements DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI {
|
||||
|
||||
@Override
|
||||
public Intent getNewLeakDisplayActivityIntent() {
|
||||
return LeakCanary.INSTANCE.newLeakDisplayActivityIntent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.settings
|
||||
|
||||
import android.content.Intent
|
||||
import leakcanary.LeakCanary.newLeakDisplayActivityIntent
|
||||
|
||||
/**
|
||||
* Build variant dependent (BVD) leak canary API implementation for the debug settings fragment.
|
||||
* This class is loaded via reflection by
|
||||
* [DebugSettingsBVDLeakCanaryAPI].
|
||||
*/
|
||||
@Suppress("unused") // Class is used but loaded via reflection
|
||||
class DebugSettingsBVDLeakCanary :
|
||||
|
||||
DebugSettingsBVDLeakCanaryAPI {
|
||||
override fun getNewLeakDisplayActivityIntent(): Intent {
|
||||
return newLeakDisplayActivityIntent()
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,10 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".ComposeActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".player.PlayQueueActivity"
|
||||
android:exported="false"
|
||||
@@ -91,20 +95,15 @@
|
||||
android:exported="false"
|
||||
android:label="@string/settings" />
|
||||
|
||||
<activity
|
||||
android:name=".about.AboutActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/title_activity_about" />
|
||||
|
||||
<service
|
||||
android:name=".local.feed.service.FeedLoadService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
|
||||
<service
|
||||
android:name=".local.feed.service.FeedLoadService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<activity
|
||||
android:name=".PanicResponderActivity"
|
||||
android:exported="true"
|
||||
@@ -124,10 +123,6 @@
|
||||
android:label="@string/general_error"
|
||||
android:theme="@android:style/Theme.NoDisplay" />
|
||||
|
||||
<activity
|
||||
android:name=".error.ErrorActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- giga get related -->
|
||||
<activity
|
||||
android:name=".download.DownloadActivity"
|
||||
@@ -135,7 +130,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
|
||||
|
||||
@@ -15,6 +15,7 @@ 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
|
||||
@@ -58,6 +59,7 @@ import org.schabi.newpipe.util.potoken.PoTokenProviderImpl
|
||||
* 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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.schabi.newpipe.error.ErrorInfo
|
||||
import org.schabi.newpipe.navigation.NavDisplay
|
||||
import org.schabi.newpipe.navigation.Screen
|
||||
import org.schabi.newpipe.ui.theme.AppTheme
|
||||
|
||||
/**
|
||||
* Single host activity for all Compose-based screens.
|
||||
* Other parts of the app (including legacy View-based code) launch this activity
|
||||
* via Intent with extras specifying which screen to display.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ComposeActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge(
|
||||
navigationBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT)
|
||||
)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
window.isNavigationBarContrastEnforced = false
|
||||
}
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val startDestination: NavKey = resolveStartDestination(intent)
|
||||
|
||||
setContent {
|
||||
AppTheme {
|
||||
NavDisplay(startDestination)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveStartDestination(intent: Intent): NavKey {
|
||||
return when (intent.getStringExtra(EXTRA_SCREEN)) {
|
||||
SCREEN_ABOUT -> Screen.About
|
||||
|
||||
SCREEN_ERROR -> Screen.Error
|
||||
|
||||
SCREEN_SETTINGS -> Screen.Settings.Home
|
||||
|
||||
else -> throw IllegalArgumentException(
|
||||
"Unknown screen: ${intent.getStringExtra(EXTRA_SCREEN)}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val SCREEN_ABOUT = "about"
|
||||
|
||||
const val EXTRA_SCREEN = "extra_screen"
|
||||
const val EXTRA_ERROR_INFO = "extra_error_info"
|
||||
|
||||
const val SCREEN_ERROR = "error"
|
||||
const val SCREEN_SETTINGS = "settings"
|
||||
|
||||
fun aboutIntent(context: Context): Intent {
|
||||
return Intent(context, ComposeActivity::class.java).apply {
|
||||
putExtra(EXTRA_SCREEN, SCREEN_ABOUT)
|
||||
}
|
||||
}
|
||||
|
||||
fun errorIntent(context: Context, errorInfo: ErrorInfo): Intent {
|
||||
return Intent(context, ComposeActivity::class.java).apply {
|
||||
putExtra(EXTRA_SCREEN, SCREEN_ERROR)
|
||||
putExtra(EXTRA_ERROR_INFO, errorInfo)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
|
||||
fun settingsIntent(context: Context): Intent {
|
||||
return Intent(context, ComposeActivity::class.java).apply {
|
||||
putExtra(EXTRA_SCREEN, SCREEN_SETTINGS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,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;
|
||||
@@ -54,7 +53,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;
|
||||
|
||||
@@ -68,13 +66,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;
|
||||
@@ -610,39 +606,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();
|
||||
@@ -701,15 +685,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);
|
||||
}
|
||||
@@ -883,7 +861,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 {
|
||||
@@ -893,7 +871,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.
|
||||
@@ -909,72 +887,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);
|
||||
|
||||
@@ -360,15 +360,9 @@ public class RouterActivity extends AppCompatActivity {
|
||||
// Default / Ask always
|
||||
final List<AdapterChoiceItem> availableChoices = choiceChecker.getAvailableChoices();
|
||||
switch (availableChoices.size()) {
|
||||
case 1:
|
||||
handleChoice(availableChoices.get(0).key);
|
||||
break;
|
||||
case 0:
|
||||
handleChoice(getString(R.string.show_info_key));
|
||||
break;
|
||||
default:
|
||||
showDialog(availableChoices);
|
||||
break;
|
||||
case 1 -> handleChoice(availableChoices.get(0).key);
|
||||
case 0 -> handleChoice(getString(R.string.show_info_key));
|
||||
default -> showDialog(availableChoices);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,7 +535,7 @@ public class RouterActivity extends AppCompatActivity {
|
||||
// Enqueue is only shown if the current queue is not empty.
|
||||
// However, if the playqueue or the player is cleared after this item was chosen and
|
||||
// while the item is extracted, it will automatically fall back to background player.
|
||||
if (PlayerHolder.getInstance().getQueueSize() > 0) {
|
||||
if (PlayerHolder.INSTANCE.getQueueSize() > 0) {
|
||||
returnedItems.add(new AdapterChoiceItem(getString(R.string.enqueue_key),
|
||||
getString(R.string.enqueue_stream), R.drawable.ic_add));
|
||||
}
|
||||
@@ -684,7 +678,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ abstract class FeedDAO {
|
||||
AND s.upload_date <> max_upload_date))
|
||||
"""
|
||||
)
|
||||
abstract fun unlinkStreamsOlderThan(offsetDateTime: OffsetDateTime)
|
||||
abstract suspend fun unlinkStreamsOlderThan(offsetDateTime: OffsetDateTime)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
|
||||
@@ -17,7 +17,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = StreamEntity::class,
|
||||
parentColumns = [StreamEntity.STREAM_ID],
|
||||
parentColumns = ["uid"],
|
||||
childColumns = [STREAM_ID],
|
||||
onDelete = ForeignKey.CASCADE,
|
||||
onUpdate = ForeignKey.CASCADE,
|
||||
|
||||
@@ -1,61 +1,66 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2022 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 androidx.paging.PagingSource
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import androidx.room.RewriteQueriesToDropUnusedColumns
|
||||
import androidx.room.RawQuery
|
||||
import androidx.sqlite.db.SimpleSQLiteQuery
|
||||
import androidx.sqlite.db.SupportSQLiteQuery
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntry
|
||||
import org.schabi.newpipe.database.stream.StreamStatisticsEntry
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.local.history.SortKey
|
||||
|
||||
@Dao
|
||||
abstract class StreamHistoryDAO : BasicDAO<StreamHistoryEntity> {
|
||||
interface StreamHistoryDAO {
|
||||
@Insert
|
||||
fun insert(entity: StreamHistoryEntity): Long
|
||||
|
||||
@Query("SELECT * FROM stream_history")
|
||||
abstract override fun getAll(): Flowable<List<StreamHistoryEntity>>
|
||||
@Delete
|
||||
fun delete(entity: StreamHistoryEntity)
|
||||
|
||||
@Query("DELETE FROM stream_history")
|
||||
abstract override fun deleteAll(): Int
|
||||
fun deleteAll(): Completable
|
||||
|
||||
override fun listByService(serviceId: Int): Flowable<List<StreamHistoryEntity>> {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
|
||||
@get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY access_date DESC")
|
||||
abstract val history: Flowable<MutableList<StreamHistoryEntry>>
|
||||
|
||||
@get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY uid ASC")
|
||||
abstract val historySortedById: Flowable<MutableList<StreamHistoryEntry>>
|
||||
@Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY uid ASC")
|
||||
fun getHistorySortedById(): Flowable<List<StreamHistoryEntry>>
|
||||
|
||||
@Query("SELECT * FROM stream_history WHERE stream_id = :streamId ORDER BY access_date DESC LIMIT 1")
|
||||
abstract fun getLatestEntry(streamId: Long): StreamHistoryEntity?
|
||||
fun getLatestEntry(streamId: Long): StreamHistoryEntity?
|
||||
|
||||
@Query("DELETE FROM stream_history WHERE stream_id = :streamId")
|
||||
abstract fun deleteStreamHistory(streamId: Long): Int
|
||||
fun deleteStreamHistory(streamId: Long): Completable
|
||||
|
||||
// Select the latest entry and watch count for each stream id on history table
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM streams
|
||||
@Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY access_date DESC")
|
||||
fun getHistory(): Flowable<List<StreamHistoryEntry>>
|
||||
|
||||
INNER JOIN (
|
||||
SELECT stream_id, MAX(access_date) AS latestAccess, SUM(repeat_count) AS watchCount
|
||||
FROM stream_history
|
||||
GROUP BY stream_id
|
||||
)
|
||||
ON uid = stream_id
|
||||
|
||||
LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state )
|
||||
ON uid = stream_id_alias
|
||||
"""
|
||||
@RawQuery(
|
||||
observedEntities = [StreamStatisticsEntry::class, StreamEntity::class, StreamHistoryEntity::class]
|
||||
)
|
||||
abstract fun getStatistics(): Flowable<MutableList<StreamStatisticsEntry>>
|
||||
fun getOrderedHistoryByRaw(query: SupportSQLiteQuery): PagingSource<Int, StreamStatisticsEntry>
|
||||
|
||||
fun getOrderedHistory(key: SortKey): PagingSource<Int, StreamStatisticsEntry> {
|
||||
val orderBy = when (key) {
|
||||
SortKey.LAST_PLAYED -> "latestAccess"
|
||||
SortKey.MOST_PLAYED -> "watchCount"
|
||||
}
|
||||
return getOrderedHistoryByRaw(
|
||||
SimpleSQLiteQuery(
|
||||
"""SELECT * FROM streams INNER JOIN
|
||||
(SELECT stream_id, MAX(access_date) AS latestAccess,
|
||||
SUM(repeat_count) AS watchCount FROM stream_history GROUP BY stream_id)
|
||||
ON uid = stream_id LEFT JOIN
|
||||
(SELECT stream_id AS stream_id_alias, progress_time FROM stream_state)
|
||||
ON uid = stream_id_alias
|
||||
ORDER BY $orderBy DESC
|
||||
""".trimIndent()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,7 @@ import androidx.room.ForeignKey
|
||||
import androidx.room.ForeignKey.Companion.CASCADE
|
||||
import androidx.room.Index
|
||||
import java.time.OffsetDateTime
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.JOIN_STREAM_ID
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_ACCESS_DATE
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
|
||||
|
||||
/**
|
||||
* @param streamUid the stream id this history item will refer to
|
||||
@@ -24,33 +20,26 @@ import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
|
||||
* @param repeatCount the total number of views this stream received
|
||||
*/
|
||||
@Entity(
|
||||
tableName = STREAM_HISTORY_TABLE,
|
||||
primaryKeys = [JOIN_STREAM_ID, STREAM_ACCESS_DATE],
|
||||
indices = [Index(value = [JOIN_STREAM_ID])],
|
||||
tableName = "stream_history",
|
||||
primaryKeys = ["stream_id", "access_date"],
|
||||
indices = [Index(value = ["stream_id"])],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = StreamEntity::class,
|
||||
parentColumns = arrayOf(STREAM_ID),
|
||||
childColumns = arrayOf(JOIN_STREAM_ID),
|
||||
parentColumns = arrayOf("uid"),
|
||||
childColumns = arrayOf("stream_id"),
|
||||
onDelete = CASCADE,
|
||||
onUpdate = CASCADE
|
||||
)
|
||||
]
|
||||
)
|
||||
data class StreamHistoryEntity(
|
||||
@ColumnInfo(name = JOIN_STREAM_ID)
|
||||
@ColumnInfo(name = "stream_id")
|
||||
val streamUid: Long,
|
||||
|
||||
@ColumnInfo(name = STREAM_ACCESS_DATE)
|
||||
@ColumnInfo(name = "access_date")
|
||||
var accessDate: OffsetDateTime,
|
||||
|
||||
@ColumnInfo(name = STREAM_REPEAT_COUNT)
|
||||
@ColumnInfo(name = "repeat_count")
|
||||
var repeatCount: Long
|
||||
) {
|
||||
companion object {
|
||||
const val STREAM_HISTORY_TABLE: String = "stream_history"
|
||||
const val STREAM_ACCESS_DATE: String = "access_date"
|
||||
const val JOIN_STREAM_ID: String = "stream_id"
|
||||
const val STREAM_REPEAT_COUNT: String = "repeat_count"
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -11,13 +11,13 @@ data class StreamHistoryEntry(
|
||||
@Embedded
|
||||
val streamEntity: StreamEntity,
|
||||
|
||||
@ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
|
||||
@ColumnInfo(name = "stream_id")
|
||||
val streamId: Long,
|
||||
|
||||
@ColumnInfo(name = StreamHistoryEntity.STREAM_ACCESS_DATE)
|
||||
@ColumnInfo(name = "access_date")
|
||||
val accessDate: OffsetDateTime,
|
||||
|
||||
@ColumnInfo(name = StreamHistoryEntity.STREAM_REPEAT_COUNT)
|
||||
@ColumnInfo(name = "repeat_count")
|
||||
val repeatCount: Long
|
||||
) {
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.room.Embedded
|
||||
import org.schabi.newpipe.database.LocalItem
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.util.image.ImageStrategy
|
||||
|
||||
@@ -19,7 +18,7 @@ data class PlaylistStreamEntry(
|
||||
@Embedded
|
||||
val streamEntity: StreamEntity,
|
||||
|
||||
@ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS, defaultValue = "0")
|
||||
@ColumnInfo(name = "progress_time", defaultValue = "0")
|
||||
val progressMillis: Long,
|
||||
|
||||
@ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID)
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
),
|
||||
ForeignKey(
|
||||
entity = StreamEntity::class,
|
||||
parentColumns = arrayOf(StreamEntity.STREAM_ID),
|
||||
parentColumns = arrayOf("uid"),
|
||||
childColumns = arrayOf(JOIN_STREAM_ID),
|
||||
onDelete = CASCADE,
|
||||
onUpdate = CASCADE,
|
||||
|
||||
@@ -11,9 +11,7 @@ import androidx.room.Embedded
|
||||
import androidx.room.Ignore
|
||||
import java.time.OffsetDateTime
|
||||
import org.schabi.newpipe.database.LocalItem
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_PROGRESS_MILLIS
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.util.image.ImageStrategy
|
||||
|
||||
@@ -21,16 +19,16 @@ data class StreamStatisticsEntry(
|
||||
@Embedded
|
||||
val streamEntity: StreamEntity,
|
||||
|
||||
@ColumnInfo(name = STREAM_PROGRESS_MILLIS, defaultValue = "0")
|
||||
@ColumnInfo(name = "progress_time", defaultValue = "0")
|
||||
val progressMillis: Long,
|
||||
|
||||
@ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
|
||||
@ColumnInfo(name = "stream_id")
|
||||
val streamId: Long,
|
||||
|
||||
@ColumnInfo(name = STREAM_LATEST_DATE)
|
||||
@ColumnInfo(name = "latestAccess")
|
||||
val latestAccessDate: OffsetDateTime,
|
||||
|
||||
@ColumnInfo(name = STREAM_WATCH_COUNT)
|
||||
@ColumnInfo(name = "watchCount")
|
||||
val watchCount: Long
|
||||
) : LocalItem {
|
||||
|
||||
@@ -51,9 +49,4 @@ data class StreamStatisticsEntry(
|
||||
thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val STREAM_LATEST_DATE = "latestAccess"
|
||||
const val STREAM_WATCH_COUNT = "watchCount"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@ package org.schabi.newpipe.database.stream
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
||||
|
||||
data class StreamWithState(
|
||||
@Embedded
|
||||
val stream: StreamEntity,
|
||||
|
||||
@ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS)
|
||||
@ColumnInfo(name = "progress_time")
|
||||
val stateProgressMillis: Long?
|
||||
)
|
||||
|
||||
@@ -8,10 +8,10 @@ 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 java.time.OffsetDateTime
|
||||
import org.schabi.newpipe.database.BasicDAO
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import org.schabi.newpipe.util.StreamTypeUtil
|
||||
|
||||
@@ -27,10 +27,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>>
|
||||
|
||||
@Query("UPDATE streams SET uploader_url = :uploaderUrl WHERE url = :url AND service_id = :serviceId")
|
||||
abstract fun setUploaderUrl(serviceId: Long, url: String, uploaderUrl: String): Completable
|
||||
abstract fun getStream(serviceId: Long, url: String): Maybe<StreamEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
internal abstract fun silentInsertInternal(stream: StreamEntity): Long
|
||||
@@ -121,28 +118,28 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
|
||||
WHERE f.stream_id = streams.uid)
|
||||
"""
|
||||
)
|
||||
abstract fun deleteOrphans(): Int
|
||||
abstract fun deleteOrphans(): Completable
|
||||
|
||||
/**
|
||||
* Minimal entry class used when comparing/updating an existent stream.
|
||||
*/
|
||||
internal data class StreamCompareFeed(
|
||||
@ColumnInfo(name = STREAM_ID)
|
||||
@ColumnInfo(name = "uid")
|
||||
var uid: Long = 0,
|
||||
|
||||
@ColumnInfo(name = StreamEntity.STREAM_TYPE)
|
||||
@ColumnInfo(name = "stream_type")
|
||||
var streamType: StreamType,
|
||||
|
||||
@ColumnInfo(name = StreamEntity.STREAM_TEXTUAL_UPLOAD_DATE)
|
||||
@ColumnInfo(name = "textual_upload_date")
|
||||
var textualUploadDate: String? = null,
|
||||
|
||||
@ColumnInfo(name = StreamEntity.STREAM_UPLOAD_DATE)
|
||||
@ColumnInfo(name = "upload_date")
|
||||
var uploadDate: OffsetDateTime? = null,
|
||||
|
||||
@ColumnInfo(name = StreamEntity.STREAM_IS_UPLOAD_DATE_APPROXIMATION)
|
||||
@ColumnInfo(name = "is_upload_date_approximation")
|
||||
var isUploadDateApproximation: Boolean? = null,
|
||||
|
||||
@ColumnInfo(name = StreamEntity.STREAM_DURATION)
|
||||
@ColumnInfo(name = "duration")
|
||||
var duration: Long
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,35 +11,39 @@ import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.Update
|
||||
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.StreamStateEntity
|
||||
|
||||
@Dao
|
||||
interface StreamStateDAO : BasicDAO<StreamStateEntity> {
|
||||
interface StreamStateDAO {
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
fun insert(entity: StreamStateEntity): Long
|
||||
|
||||
@Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE)
|
||||
override fun getAll(): Flowable<List<StreamStateEntity>>
|
||||
@Update
|
||||
fun update(entity: StreamStateEntity)
|
||||
|
||||
@Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE)
|
||||
override fun deleteAll(): Int
|
||||
@Query("SELECT * FROM stream_state")
|
||||
fun getAll(): Flowable<List<StreamStateEntity>>
|
||||
|
||||
override fun listByService(serviceId: Int): Flowable<List<StreamStateEntity>> {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
@Query("DELETE FROM stream_state")
|
||||
fun deleteAll(): Completable
|
||||
|
||||
@Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
|
||||
fun getState(streamId: Long): Flowable<MutableList<StreamStateEntity>>
|
||||
@Query("SELECT * FROM stream_state WHERE stream_id = :streamId")
|
||||
fun getState(streamId: Long): Maybe<StreamStateEntity>
|
||||
|
||||
@Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
|
||||
fun deleteState(streamId: Long): Int
|
||||
@Query("DELETE FROM stream_state WHERE stream_id = :streamId")
|
||||
fun deleteState(streamId: Long): Completable
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.Companion.IGNORE)
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
fun silentInsertInternal(streamState: StreamStateEntity)
|
||||
|
||||
@Transaction
|
||||
fun upsert(stream: StreamStateEntity): Long {
|
||||
fun upsert(stream: StreamStateEntity) {
|
||||
silentInsertInternal(stream)
|
||||
return update(stream).toLong()
|
||||
update(stream)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,6 @@ import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import java.io.Serializable
|
||||
import java.time.OffsetDateTime
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_SERVICE_ID
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_TABLE
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_URL
|
||||
import org.schabi.newpipe.extractor.localization.DateWrapper
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
@@ -18,50 +15,50 @@ import org.schabi.newpipe.player.playqueue.PlayQueueItem
|
||||
import org.schabi.newpipe.util.image.ImageStrategy
|
||||
|
||||
@Entity(
|
||||
tableName = STREAM_TABLE,
|
||||
tableName = "streams",
|
||||
indices = [
|
||||
Index(value = [STREAM_SERVICE_ID, STREAM_URL], unique = true)
|
||||
Index(value = ["service_id", "url"], unique = true)
|
||||
]
|
||||
)
|
||||
data class StreamEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = STREAM_ID)
|
||||
@ColumnInfo(name = "uid")
|
||||
var uid: Long = 0,
|
||||
|
||||
@ColumnInfo(name = STREAM_SERVICE_ID)
|
||||
@ColumnInfo(name = "service_id")
|
||||
var serviceId: Int,
|
||||
|
||||
@ColumnInfo(name = STREAM_URL)
|
||||
@ColumnInfo(name = "url")
|
||||
var url: String,
|
||||
|
||||
@ColumnInfo(name = STREAM_TITLE)
|
||||
@ColumnInfo(name = "title")
|
||||
var title: String,
|
||||
|
||||
@ColumnInfo(name = STREAM_TYPE)
|
||||
@ColumnInfo(name = "stream_type")
|
||||
var streamType: StreamType,
|
||||
|
||||
@ColumnInfo(name = STREAM_DURATION)
|
||||
@ColumnInfo(name = "duration")
|
||||
var duration: Long,
|
||||
|
||||
@ColumnInfo(name = STREAM_UPLOADER)
|
||||
@ColumnInfo(name = "uploader")
|
||||
var uploader: String,
|
||||
|
||||
@ColumnInfo(name = STREAM_UPLOADER_URL)
|
||||
@ColumnInfo(name = "uploader_url")
|
||||
var uploaderUrl: String? = null,
|
||||
|
||||
@ColumnInfo(name = STREAM_THUMBNAIL_URL)
|
||||
@ColumnInfo(name = "thumbnail_url")
|
||||
var thumbnailUrl: String? = null,
|
||||
|
||||
@ColumnInfo(name = STREAM_VIEWS)
|
||||
@ColumnInfo(name = "view_count")
|
||||
var viewCount: Long? = null,
|
||||
|
||||
@ColumnInfo(name = STREAM_TEXTUAL_UPLOAD_DATE)
|
||||
@ColumnInfo(name = "textual_upload_date")
|
||||
var textualUploadDate: String? = null,
|
||||
|
||||
@ColumnInfo(name = STREAM_UPLOAD_DATE)
|
||||
@ColumnInfo(name = "upload_date")
|
||||
var uploadDate: OffsetDateTime? = null,
|
||||
|
||||
@ColumnInfo(name = STREAM_IS_UPLOAD_DATE_APPROXIMATION)
|
||||
@ColumnInfo(name = "is_upload_date_approximation")
|
||||
var isUploadDateApproximation: Boolean? = null
|
||||
) : Serializable {
|
||||
@Ignore
|
||||
@@ -111,22 +108,4 @@ data class StreamEntity(
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val STREAM_TABLE = "streams"
|
||||
const val STREAM_ID = "uid"
|
||||
const val STREAM_SERVICE_ID = "service_id"
|
||||
const val STREAM_URL = "url"
|
||||
const val STREAM_TITLE = "title"
|
||||
const val STREAM_TYPE = "stream_type"
|
||||
const val STREAM_DURATION = "duration"
|
||||
const val STREAM_UPLOADER = "uploader"
|
||||
const val STREAM_UPLOADER_URL = "uploader_url"
|
||||
const val STREAM_THUMBNAIL_URL = "thumbnail_url"
|
||||
|
||||
const val STREAM_VIEWS = "view_count"
|
||||
const val STREAM_TEXTUAL_UPLOAD_DATE = "textual_upload_date"
|
||||
const val STREAM_UPLOAD_DATE = "upload_date"
|
||||
const val STREAM_IS_UPLOAD_DATE_APPROXIMATION = "is_upload_date_approximation"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,29 +10,26 @@ import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.ForeignKey.Companion.CASCADE
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.JOIN_STREAM_ID
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.PLAYBACK_FINISHED_END_MILLISECONDS
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_STATE_TABLE
|
||||
|
||||
@Entity(
|
||||
tableName = STREAM_STATE_TABLE,
|
||||
primaryKeys = [JOIN_STREAM_ID],
|
||||
tableName = "stream_state",
|
||||
primaryKeys = ["stream_id"],
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = StreamEntity::class,
|
||||
parentColumns = arrayOf(STREAM_ID),
|
||||
childColumns = arrayOf(JOIN_STREAM_ID),
|
||||
parentColumns = arrayOf("uid"),
|
||||
childColumns = arrayOf("stream_id"),
|
||||
onDelete = CASCADE,
|
||||
onUpdate = CASCADE
|
||||
)
|
||||
]
|
||||
)
|
||||
data class StreamStateEntity(
|
||||
@ColumnInfo(name = JOIN_STREAM_ID)
|
||||
@ColumnInfo(name = "stream_id")
|
||||
val streamUid: Long,
|
||||
|
||||
@ColumnInfo(name = STREAM_PROGRESS_MILLIS)
|
||||
@ColumnInfo(name = "progress_time")
|
||||
val progressMillis: Long
|
||||
) {
|
||||
/**
|
||||
@@ -61,14 +58,6 @@ data class StreamStateEntity(
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val STREAM_STATE_TABLE = "stream_state"
|
||||
const val JOIN_STREAM_ID = "stream_id"
|
||||
|
||||
// This additional field is required for the SQL query because 'stream_id' is used
|
||||
// for some other joins already
|
||||
const val JOIN_STREAM_ID_ALIAS = "stream_id_alias"
|
||||
const val STREAM_PROGRESS_MILLIS = "progress_time"
|
||||
|
||||
/**
|
||||
* Playback state will not be saved, if playback time is less than this threshold
|
||||
* (5000ms = 5s).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2015-2026 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.error
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.grack.nanojson.JsonWriter
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.databinding.ActivityErrorBinding
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.ThemeHelper
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
import org.schabi.newpipe.util.text.setTextWithLinks
|
||||
|
||||
/**
|
||||
* This activity is used to show error details and allow reporting them in various ways.
|
||||
* Use [ErrorUtil.openActivity] to correctly open this activity.
|
||||
*/
|
||||
class ErrorActivity : AppCompatActivity() {
|
||||
private lateinit var errorInfo: ErrorInfo
|
||||
private lateinit var currentTimeStamp: String
|
||||
|
||||
private lateinit var binding: ActivityErrorBinding
|
||||
|
||||
private val contentCountryString: String
|
||||
get() = Localization.getPreferredContentCountry(this).countryCode
|
||||
|
||||
private val contentLanguageString: String
|
||||
get() = Localization.getPreferredLocalization(this).localizationCode
|
||||
|
||||
private val appLanguage: String
|
||||
get() = Localization.getAppLocale().toString()
|
||||
|
||||
private val osString: String
|
||||
get() {
|
||||
val name = System.getProperty("os.name")!!
|
||||
val osBase = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
Build.VERSION.BASE_OS.ifEmpty { "Android" }
|
||||
} else {
|
||||
"Android"
|
||||
}
|
||||
return "$name $osBase ${Build.VERSION.RELEASE} - ${Build.VERSION.SDK_INT}"
|
||||
}
|
||||
|
||||
private val errorEmailSubject: String
|
||||
get() = "$ERROR_EMAIL_SUBJECT ${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME}"
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////
|
||||
// Activity lifecycle
|
||||
// /////////////////////////////////////////////////////////////////////
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
ThemeHelper.setDayNightMode(this)
|
||||
ThemeHelper.setTheme(this)
|
||||
|
||||
binding = ActivityErrorBinding.inflate(layoutInflater)
|
||||
setContentView(binding.getRoot())
|
||||
|
||||
setSupportActionBar(binding.toolbarLayout.toolbar)
|
||||
supportActionBar?.apply {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setTitle(R.string.error_report_title)
|
||||
setDisplayShowTitleEnabled(true)
|
||||
}
|
||||
|
||||
errorInfo = IntentCompat.getParcelableExtra(intent, ERROR_INFO, ErrorInfo::class.java)!!
|
||||
|
||||
// important add guru meditation
|
||||
addGuruMeditation()
|
||||
// print current time, as zoned ISO8601 timestamp
|
||||
currentTimeStamp = ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
|
||||
|
||||
binding.errorReportEmailButton.setOnClickListener { _ ->
|
||||
openPrivacyPolicyDialog(this, "EMAIL")
|
||||
}
|
||||
|
||||
binding.errorReportCopyButton.setOnClickListener { _ ->
|
||||
ShareUtils.copyToClipboard(this, buildMarkdown())
|
||||
}
|
||||
|
||||
binding.errorReportGitHubButton.setOnClickListener { _ ->
|
||||
openPrivacyPolicyDialog(this, "GITHUB")
|
||||
}
|
||||
|
||||
// normal bugreport
|
||||
buildInfo(errorInfo)
|
||||
binding.errorMessageView.setTextWithLinks(errorInfo.getMessage(this))
|
||||
binding.errorView.text = formErrorText(errorInfo.stackTraces)
|
||||
|
||||
// print stack trace once again for debugging:
|
||||
errorInfo.stackTraces.forEach { Log.e(TAG, it) }
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.error_menu, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
onBackPressed()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.menu_item_share_error -> {
|
||||
ShareUtils.shareText(
|
||||
applicationContext,
|
||||
getString(R.string.error_report_title),
|
||||
buildJson()
|
||||
)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun openPrivacyPolicyDialog(context: Context, action: String) {
|
||||
AlertDialog.Builder(context)
|
||||
.setIcon(android.R.drawable.ic_dialog_alert)
|
||||
.setTitle(R.string.privacy_policy_title)
|
||||
.setMessage(R.string.start_accept_privacy_policy)
|
||||
.setCancelable(false)
|
||||
.setNeutralButton(R.string.read_privacy_policy) { _, _ ->
|
||||
ShareUtils.openUrlInApp(context, context.getString(R.string.privacy_policy_url))
|
||||
}
|
||||
.setPositiveButton(R.string.accept) { _, _ ->
|
||||
if (action == "EMAIL") { // send on email
|
||||
val intent = Intent(Intent.ACTION_SENDTO)
|
||||
.setData("mailto:".toUri()) // only email apps should handle this
|
||||
.putExtra(Intent.EXTRA_EMAIL, arrayOf(ERROR_EMAIL_ADDRESS))
|
||||
.putExtra(Intent.EXTRA_SUBJECT, errorEmailSubject)
|
||||
.putExtra(Intent.EXTRA_TEXT, buildJson())
|
||||
ShareUtils.openIntentInApp(context, intent)
|
||||
} else if (action == "GITHUB") { // open the NewPipe issue page on GitHub
|
||||
ShareUtils.openUrlInApp(this, ERROR_GITHUB_ISSUE_URL)
|
||||
}
|
||||
}
|
||||
.setNegativeButton(R.string.decline, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun formErrorText(stacktrace: Array<String>): String {
|
||||
val separator = "-------------------------------------"
|
||||
return stacktrace.joinToString(separator + "\n", separator + "\n", separator)
|
||||
}
|
||||
|
||||
private fun buildInfo(info: ErrorInfo) {
|
||||
binding.errorInfoLabelsView.text = getString(R.string.info_labels)
|
||||
|
||||
val text = info.userAction.message + "\n" +
|
||||
info.request + "\n" +
|
||||
contentLanguageString + "\n" +
|
||||
contentCountryString + "\n" +
|
||||
appLanguage + "\n" +
|
||||
info.getServiceName() + "\n" +
|
||||
currentTimeStamp + "\n" +
|
||||
packageName + "\n" +
|
||||
BuildConfig.VERSION_NAME + "\n" +
|
||||
osString
|
||||
|
||||
binding.errorInfosView.text = text
|
||||
}
|
||||
|
||||
private fun buildJson(): String {
|
||||
try {
|
||||
return JsonWriter.string()
|
||||
.`object`()
|
||||
.value("user_action", errorInfo.userAction.message)
|
||||
.value("request", errorInfo.request)
|
||||
.value("content_language", contentLanguageString)
|
||||
.value("content_country", contentCountryString)
|
||||
.value("app_language", appLanguage)
|
||||
.value("service", errorInfo.getServiceName())
|
||||
.value("package", packageName)
|
||||
.value("version", BuildConfig.VERSION_NAME)
|
||||
.value("os", osString)
|
||||
.value("time", currentTimeStamp)
|
||||
.array("exceptions", errorInfo.stackTraces.toList())
|
||||
.value("user_comment", binding.errorCommentBox.getText().toString())
|
||||
.end()
|
||||
.done()
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Error while erroring: Could not build json", exception)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
private fun buildMarkdown(): String {
|
||||
try {
|
||||
return buildString(1024) {
|
||||
val userComment = binding.errorCommentBox.text.toString()
|
||||
if (userComment.isNotEmpty()) {
|
||||
appendLine(userComment)
|
||||
}
|
||||
|
||||
// basic error info
|
||||
appendLine("## Exception")
|
||||
appendLine("* __User Action:__ ${errorInfo.userAction.message}")
|
||||
appendLine("* __Request:__ ${errorInfo.request}")
|
||||
appendLine("* __Content Country:__ $contentCountryString")
|
||||
appendLine("* __Content Language:__ $contentLanguageString")
|
||||
appendLine("* __App Language:__ $appLanguage")
|
||||
appendLine("* __Service:__ ${errorInfo.getServiceName()}")
|
||||
appendLine("* __Timestamp:__ $currentTimeStamp")
|
||||
appendLine("* __Package:__ $packageName")
|
||||
appendLine("* __Service:__ ${errorInfo.getServiceName()}")
|
||||
appendLine("* __Version:__ ${BuildConfig.VERSION_NAME}")
|
||||
appendLine("* __OS:__ $osString")
|
||||
|
||||
// Collapse all logs to a single paragraph when there are more than one
|
||||
// to keep the GitHub issue clean.
|
||||
if (errorInfo.stackTraces.size > 1) {
|
||||
append("<details><summary><b>Exceptions (")
|
||||
append(errorInfo.stackTraces.size)
|
||||
append(")</b></summary><p>\n")
|
||||
}
|
||||
|
||||
// add the logs
|
||||
errorInfo.stackTraces.forEachIndexed { index, stacktrace ->
|
||||
append("<details><summary><b>Crash log ")
|
||||
if (errorInfo.stackTraces.size > 1) {
|
||||
append(index + 1)
|
||||
}
|
||||
append("</b>")
|
||||
append("</summary><p>\n")
|
||||
append("\n```\n${stacktrace}\n```\n")
|
||||
append("</details>\n")
|
||||
}
|
||||
|
||||
// make sure to close everything
|
||||
if (errorInfo.stackTraces.size > 1) {
|
||||
append("</p></details>\n")
|
||||
}
|
||||
|
||||
append("<hr>\n")
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Error while erroring: Could not build markdown", exception)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun addGuruMeditation() {
|
||||
// just an easter egg
|
||||
var text = binding.errorSorryView.text.toString()
|
||||
text += "\n" + getString(R.string.guru_meditation)
|
||||
binding.errorSorryView.text = text
|
||||
}
|
||||
|
||||
companion object {
|
||||
// LOG TAGS
|
||||
private val TAG = ErrorActivity::class.java.toString()
|
||||
|
||||
// BUNDLE TAGS
|
||||
const val ERROR_INFO = "error_info"
|
||||
|
||||
private const val ERROR_EMAIL_ADDRESS = "crashreport@newpipe.schabi.org"
|
||||
private const val ERROR_EMAIL_SUBJECT = "Exception in "
|
||||
|
||||
private const val ERROR_GITHUB_ISSUE_URL = "https://github.com/TeamNewPipe/NewPipe/issues"
|
||||
}
|
||||
}
|
||||
@@ -166,7 +166,7 @@ class ErrorInfo private constructor(
|
||||
|
||||
private fun getServiceName(serviceId: Int?) = // not using getNameOfServiceById since we want to accept a nullable serviceId and we
|
||||
// want to default to SERVICE_NONE
|
||||
ServiceList.all().firstOrNull { it.serviceId == serviceId }?.serviceInfo?.name
|
||||
ServiceList.all()?.firstOrNull { it.serviceId == serviceId }?.serviceInfo?.name
|
||||
?: SERVICE_NONE
|
||||
|
||||
fun throwableToStringList(throwable: Throwable) = arrayOf(throwable.stackTraceToString())
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2015-2026 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.error
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import com.grack.nanojson.JsonWriter
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
|
||||
/**
|
||||
* Pure utility functions for building and sending error reports.
|
||||
* No Activity dependency — only requires a [Context].
|
||||
*/
|
||||
object ErrorReportHelper {
|
||||
|
||||
private val TAG = ErrorReportHelper::class.java.simpleName
|
||||
|
||||
private const val ERROR_EMAIL_ADDRESS = "crashreport@newpipe.schabi.org"
|
||||
private const val ERROR_EMAIL_SUBJECT = "Exception in "
|
||||
const val ERROR_GITHUB_ISSUE_URL = "https://github.com/TeamNewPipe/NewPipe/issues"
|
||||
|
||||
fun buildJson(context: Context, errorInfo: ErrorInfo, comment: String): String {
|
||||
try {
|
||||
return JsonWriter.string()
|
||||
.`object`()
|
||||
.value("user_action", errorInfo.userAction.message)
|
||||
.value("request", errorInfo.request)
|
||||
.value("content_language", getContentLanguage(context))
|
||||
.value("content_country", getContentCountry(context))
|
||||
.value("app_language", getAppLanguage())
|
||||
.value("service", errorInfo.getServiceName())
|
||||
.value("package", context.packageName)
|
||||
.value("version", BuildConfig.VERSION_NAME)
|
||||
.value("os", getOsString())
|
||||
.value("time", getCurrentTimestamp())
|
||||
.array("exceptions", errorInfo.stackTraces.toList())
|
||||
.value("user_comment", comment)
|
||||
.end()
|
||||
.done()
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Could not build json", exception)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
fun buildMarkdown(context: Context, errorInfo: ErrorInfo, comment: String): String {
|
||||
try {
|
||||
return buildString(1024) {
|
||||
if (comment.isNotEmpty()) {
|
||||
appendLine(comment)
|
||||
}
|
||||
|
||||
appendLine("## Exception")
|
||||
appendLine("* __User Action:__ ${errorInfo.userAction.message}")
|
||||
appendLine("* __Request:__ ${errorInfo.request}")
|
||||
appendLine("* __Content Country:__ ${getContentCountry(context)}")
|
||||
appendLine("* __Content Language:__ ${getContentLanguage(context)}")
|
||||
appendLine("* __App Language:__ ${getAppLanguage()}")
|
||||
appendLine("* __Service:__ ${errorInfo.getServiceName()}")
|
||||
appendLine("* __Timestamp:__ ${getCurrentTimestamp()}")
|
||||
appendLine("* __Package:__ ${context.packageName}")
|
||||
appendLine("* __Version:__ ${BuildConfig.VERSION_NAME}")
|
||||
appendLine("* __OS:__ ${getOsString()}")
|
||||
|
||||
if (errorInfo.stackTraces.size > 1) {
|
||||
append("<details><summary><b>Exceptions (")
|
||||
append(errorInfo.stackTraces.size)
|
||||
append(")</b></summary><p>\n")
|
||||
}
|
||||
|
||||
errorInfo.stackTraces.forEachIndexed { index, stacktrace ->
|
||||
append("<details><summary><b>Crash log ")
|
||||
if (errorInfo.stackTraces.size > 1) {
|
||||
append(index + 1)
|
||||
}
|
||||
append("</b></summary><p>\n")
|
||||
append("\n```\n$stacktrace\n```\n")
|
||||
append("</details>\n")
|
||||
}
|
||||
|
||||
if (errorInfo.stackTraces.size > 1) {
|
||||
append("</p></details>\n")
|
||||
}
|
||||
|
||||
append("<hr>\n")
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Could not build markdown", exception)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
fun sendErrorEmail(context: Context, errorInfo: ErrorInfo, comment: String) {
|
||||
val subject = "$ERROR_EMAIL_SUBJECT${context.getString(R.string.app_name)} ${BuildConfig.VERSION_NAME}"
|
||||
val intent = Intent(Intent.ACTION_SENDTO)
|
||||
.setData("mailto:".toUri())
|
||||
.putExtra(Intent.EXTRA_EMAIL, arrayOf(ERROR_EMAIL_ADDRESS))
|
||||
.putExtra(Intent.EXTRA_SUBJECT, subject)
|
||||
.putExtra(Intent.EXTRA_TEXT, buildJson(context, errorInfo, comment))
|
||||
ShareUtils.openIntentInApp(context, intent)
|
||||
}
|
||||
|
||||
fun shareError(context: Context, errorInfo: ErrorInfo, comment: String) {
|
||||
ShareUtils.shareText(
|
||||
context,
|
||||
context.getString(R.string.error_report_title),
|
||||
buildJson(context, errorInfo, comment)
|
||||
)
|
||||
}
|
||||
|
||||
fun copyForGitHub(context: Context, errorInfo: ErrorInfo, comment: String) {
|
||||
ShareUtils.copyToClipboard(context, buildMarkdown(context, errorInfo, comment))
|
||||
}
|
||||
|
||||
fun openGitHubIssues(context: Context) {
|
||||
ShareUtils.openUrlInApp(context, ERROR_GITHUB_ISSUE_URL)
|
||||
}
|
||||
|
||||
fun openPrivacyPolicy(context: Context) {
|
||||
ShareUtils.openUrlInApp(context, context.getString(R.string.privacy_policy_url))
|
||||
}
|
||||
|
||||
private fun getContentLanguage(context: Context): String = Localization.getPreferredLocalization(context).localizationCode
|
||||
|
||||
private fun getContentCountry(context: Context): String = Localization.getPreferredContentCountry(context).countryCode
|
||||
|
||||
private fun getAppLanguage(): String = Localization.getAppLocale().toString()
|
||||
|
||||
private fun getOsString(): String {
|
||||
val name = System.getProperty("os.name")!!
|
||||
val osBase = Build.VERSION.BASE_OS.ifEmpty { "Android" }
|
||||
return "$name $osBase ${Build.VERSION.RELEASE} - ${Build.VERSION.SDK_INT}"
|
||||
}
|
||||
|
||||
private fun getCurrentTimestamp(): String = ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.schabi.newpipe.ComposeActivity
|
||||
import org.schabi.newpipe.MainActivity
|
||||
import org.schabi.newpipe.R
|
||||
|
||||
@@ -25,7 +26,7 @@ import org.schabi.newpipe.R
|
||||
* is available.
|
||||
* - Use a notification if the exception happens inside a background service (player, subscription
|
||||
* import, ...) or there is no activity/fragment from which to extract a root view.
|
||||
* - Finally use the error activity only as a last resort in case the exception is critical and
|
||||
* - Finally use the error screen only as a last resort in case the exception is critical and
|
||||
* happens in an open activity (since the workflow would be interrupted anyway in that case).
|
||||
*/
|
||||
class ErrorUtil {
|
||||
@@ -33,7 +34,7 @@ class ErrorUtil {
|
||||
private const val ERROR_REPORT_NOTIFICATION_ID = 5340681
|
||||
|
||||
/**
|
||||
* Starts a new error activity allowing the user to report the provided error. Only use this
|
||||
* Starts a new error screen allowing the user to report the provided error. Only use this
|
||||
* method directly as a last resort in case the exception is critical and happens in an open
|
||||
* activity (since the workflow would be interrupted anyway in that case). So never use this
|
||||
* for background services.
|
||||
@@ -50,12 +51,12 @@ class ErrorUtil {
|
||||
) {
|
||||
createNotification(context, errorInfo)
|
||||
} else {
|
||||
context.startActivity(getErrorActivityIntent(context, errorInfo))
|
||||
context.startActivity(getErrorScreenIntent(context, errorInfo))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a bottom snackbar to the user, with a report button that opens the error activity.
|
||||
* Show a bottom snackbar to the user, with a report button that opens the error screen.
|
||||
* Use this method if the exception is not critical and it happens in a place where a root
|
||||
* view is available.
|
||||
*
|
||||
@@ -70,7 +71,7 @@ class ErrorUtil {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a bottom snackbar to the user, with a report button that opens the error activity.
|
||||
* Show a bottom snackbar to the user, with a report button that opens the error screen.
|
||||
* Use this method if the exception is not critical and it happens in a place where a root
|
||||
* view is available.
|
||||
*
|
||||
@@ -104,7 +105,7 @@ class ErrorUtil {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error notification. Tapping on the notification opens the error activity. Use
|
||||
* Create an error notification. Tapping on the notification opens the error screen. Use
|
||||
* this method if the exception happens inside a background service (player, subscription
|
||||
* import, ...) or there is no activity/fragment from which to extract a root view.
|
||||
*
|
||||
@@ -128,7 +129,7 @@ class ErrorUtil {
|
||||
PendingIntentCompat.getActivity(
|
||||
context,
|
||||
0,
|
||||
getErrorActivityIntent(context, errorInfo),
|
||||
getErrorScreenIntent(context, errorInfo),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
false
|
||||
)
|
||||
@@ -147,11 +148,8 @@ class ErrorUtil {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getErrorActivityIntent(context: Context, errorInfo: ErrorInfo): Intent {
|
||||
val intent = Intent(context, ErrorActivity::class.java)
|
||||
intent.putExtra(ErrorActivity.ERROR_INFO, errorInfo)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
return intent
|
||||
private fun getErrorScreenIntent(context: Context, errorInfo: ErrorInfo): Intent {
|
||||
return ComposeActivity.errorIntent(context, errorInfo)
|
||||
}
|
||||
|
||||
private fun showSnackbar(context: Context, rootView: View?, errorInfo: ErrorInfo) {
|
||||
@@ -162,7 +160,7 @@ class ErrorUtil {
|
||||
Snackbar.make(rootView, errorInfo.getMessage(context), Snackbar.LENGTH_LONG)
|
||||
.setActionTextColor(Color.YELLOW)
|
||||
.setAction(context.getString(R.string.error_snackbar_action).uppercase()) {
|
||||
context.startActivity(getErrorActivityIntent(context, errorInfo))
|
||||
context.startActivity(getErrorScreenIntent(context, errorInfo))
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -194,6 +195,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);
|
||||
@@ -647,8 +650,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();
|
||||
|
||||
-172
@@ -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.CoilHelper;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
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
|
||||
CoilHelper.INSTANCE.loadAvatar(binding.authorAvatar, item.getUploaderAvatars());
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -10,12 +11,19 @@ import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Typeface;
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.Html;
|
||||
import android.text.Layout;
|
||||
import android.text.SpannableString;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
import android.text.style.AlignmentSpan;
|
||||
import android.text.style.CharacterStyle;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.text.style.StyleSpan;
|
||||
import android.util.Log;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.LayoutInflater;
|
||||
@@ -65,6 +73,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 +364,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);
|
||||
@@ -453,22 +464,28 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
|
||||
for (final String filter : service.getSearchQHFactory().getAvailableContentFilter()) {
|
||||
if (filter.equals(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS)) {
|
||||
final MenuItem musicItem = menu.add(2,
|
||||
menu.add(2,
|
||||
itemId++,
|
||||
0,
|
||||
"YouTube Music");
|
||||
musicItem.setEnabled(false);
|
||||
getStyledHeader("YouTube"));
|
||||
menu.add(2,
|
||||
itemId++,
|
||||
1,
|
||||
getStyledHeader("YouTube Music"));
|
||||
} else if (filter.equals(PeertubeSearchQueryHandlerFactory.SEPIA_VIDEOS)) {
|
||||
final MenuItem sepiaItem = menu.add(2,
|
||||
menu.add(2,
|
||||
itemId++,
|
||||
0,
|
||||
"Sepia Search");
|
||||
sepiaItem.setEnabled(false);
|
||||
getStyledHeader("Search"));
|
||||
menu.add(2,
|
||||
itemId++,
|
||||
1,
|
||||
getStyledHeader("Sepia Search"));
|
||||
}
|
||||
menuItemToFilterName.put(itemId, filter);
|
||||
final MenuItem item = menu.add(1,
|
||||
itemId++,
|
||||
0,
|
||||
1,
|
||||
ServiceHelper.getTranslatedFilterString(filter, c));
|
||||
if (isFirstItem) {
|
||||
item.setChecked(true);
|
||||
@@ -480,6 +497,15 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
restoreFilterChecked(menu, filterItemCheckedId);
|
||||
}
|
||||
|
||||
private SpannableString getStyledHeader(final String title) {
|
||||
final SpannableString styledTitle = new SpannableString(title);
|
||||
styledTitle.setSpan(new StyleSpan(Typeface.BOLD), 0, title.length(), 0);
|
||||
styledTitle.setSpan(new ForegroundColorSpan(Color.GRAY), 0, title.length(), 0);
|
||||
styledTitle.setSpan(new AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER),
|
||||
0, title.length(), 0);
|
||||
return styledTitle;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
|
||||
final var filter = Collections.singletonList(menuItemToFilterName.get(item.getItemId()));
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -45,10 +45,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.
|
||||
|
||||
@@ -1,210 +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.CoilHelper;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
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 item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// load the author avatar
|
||||
CoilHelper.INSTANCE.loadAvatar(itemThumbnailView, item.getUploaderAvatars());
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.schabi.newpipe.ktx
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import kotlinx.serialization.json.Json
|
||||
import net.newpipe.Constants
|
||||
import net.newpipe.app.ComposeActivity
|
||||
import net.newpipe.app.navigation.Destination
|
||||
|
||||
tailrec fun Context.findFragmentActivity(): FragmentActivity {
|
||||
return when (this) {
|
||||
is FragmentActivity -> this
|
||||
is ContextWrapper -> baseContext.findFragmentActivity()
|
||||
else -> throw IllegalStateException("Unable to find FragmentActivity")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to a given compose destination
|
||||
*/
|
||||
fun Context.navigateTo(destination: Destination) {
|
||||
val intent = Intent(this, ComposeActivity::class.java)
|
||||
.putExtra(Constants.INTENT_SCREEN_KEY, Json.encodeToString(destination))
|
||||
startActivity(intent)
|
||||
}
|
||||
@@ -22,9 +22,6 @@ import org.schabi.newpipe.local.holder.LocalPlaylistItemHolder;
|
||||
import org.schabi.newpipe.local.holder.LocalPlaylistStreamCardItemHolder;
|
||||
import org.schabi.newpipe.local.holder.LocalPlaylistStreamGridItemHolder;
|
||||
import org.schabi.newpipe.local.holder.LocalPlaylistStreamItemHolder;
|
||||
import org.schabi.newpipe.local.holder.LocalStatisticStreamCardItemHolder;
|
||||
import org.schabi.newpipe.local.holder.LocalStatisticStreamGridItemHolder;
|
||||
import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder;
|
||||
import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder;
|
||||
import org.schabi.newpipe.local.holder.RemotePlaylistCardItemHolder;
|
||||
import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder;
|
||||
@@ -66,10 +63,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||
private static final int HEADER_TYPE = 0;
|
||||
private static final int FOOTER_TYPE = 1;
|
||||
|
||||
private static final int STREAM_STATISTICS_HOLDER_TYPE = 0x1000;
|
||||
private static final int STREAM_PLAYLIST_HOLDER_TYPE = 0x1001;
|
||||
private static final int STREAM_STATISTICS_GRID_HOLDER_TYPE = 0x1002;
|
||||
private static final int STREAM_STATISTICS_CARD_HOLDER_TYPE = 0x1003;
|
||||
private static final int STREAM_PLAYLIST_GRID_HOLDER_TYPE = 0x1004;
|
||||
private static final int STREAM_PLAYLIST_CARD_HOLDER_TYPE = 0x1005;
|
||||
|
||||
@@ -303,14 +297,6 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||
} else {
|
||||
return STREAM_PLAYLIST_HOLDER_TYPE;
|
||||
}
|
||||
case STATISTIC_STREAM_ITEM:
|
||||
if (itemViewMode == ItemViewMode.CARD) {
|
||||
return STREAM_STATISTICS_CARD_HOLDER_TYPE;
|
||||
} else if (itemViewMode == ItemViewMode.GRID) {
|
||||
return STREAM_STATISTICS_GRID_HOLDER_TYPE;
|
||||
} else {
|
||||
return STREAM_STATISTICS_HOLDER_TYPE;
|
||||
}
|
||||
default:
|
||||
Log.e(TAG, "No holder type has been considered for item: ["
|
||||
+ item.getLocalItemType() + "]");
|
||||
@@ -326,43 +312,36 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||
Log.d(TAG, "onCreateViewHolder() called with: "
|
||||
+ "parent = [" + parent + "], type = [" + type + "]");
|
||||
}
|
||||
switch (type) {
|
||||
case HEADER_TYPE:
|
||||
return new HeaderFooterHolder(headerSupplier.get());
|
||||
case FOOTER_TYPE:
|
||||
return new HeaderFooterHolder(footer);
|
||||
case LOCAL_PLAYLIST_HOLDER_TYPE:
|
||||
return new LocalPlaylistItemHolder(localItemBuilder, parent);
|
||||
case LOCAL_PLAYLIST_GRID_HOLDER_TYPE:
|
||||
return new LocalPlaylistGridItemHolder(localItemBuilder, parent);
|
||||
case LOCAL_PLAYLIST_CARD_HOLDER_TYPE:
|
||||
return new LocalPlaylistCardItemHolder(localItemBuilder, parent);
|
||||
case LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE:
|
||||
return new LocalBookmarkPlaylistItemHolder(localItemBuilder, parent);
|
||||
case REMOTE_PLAYLIST_HOLDER_TYPE:
|
||||
return new RemotePlaylistItemHolder(localItemBuilder, parent);
|
||||
case REMOTE_PLAYLIST_GRID_HOLDER_TYPE:
|
||||
return new RemotePlaylistGridItemHolder(localItemBuilder, parent);
|
||||
case REMOTE_PLAYLIST_CARD_HOLDER_TYPE:
|
||||
return new RemotePlaylistCardItemHolder(localItemBuilder, parent);
|
||||
case REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE:
|
||||
return new RemoteBookmarkPlaylistItemHolder(localItemBuilder, parent);
|
||||
case STREAM_PLAYLIST_HOLDER_TYPE:
|
||||
return new LocalPlaylistStreamItemHolder(localItemBuilder, parent);
|
||||
case STREAM_PLAYLIST_GRID_HOLDER_TYPE:
|
||||
return new LocalPlaylistStreamGridItemHolder(localItemBuilder, parent);
|
||||
case STREAM_PLAYLIST_CARD_HOLDER_TYPE:
|
||||
return new LocalPlaylistStreamCardItemHolder(localItemBuilder, parent);
|
||||
case STREAM_STATISTICS_HOLDER_TYPE:
|
||||
return new LocalStatisticStreamItemHolder(localItemBuilder, parent);
|
||||
case STREAM_STATISTICS_GRID_HOLDER_TYPE:
|
||||
return new LocalStatisticStreamGridItemHolder(localItemBuilder, parent);
|
||||
case STREAM_STATISTICS_CARD_HOLDER_TYPE:
|
||||
return new LocalStatisticStreamCardItemHolder(localItemBuilder, parent);
|
||||
default:
|
||||
return switch (type) {
|
||||
case HEADER_TYPE -> new HeaderFooterHolder(headerSupplier.get());
|
||||
case FOOTER_TYPE -> new HeaderFooterHolder(footer);
|
||||
case LOCAL_PLAYLIST_HOLDER_TYPE ->
|
||||
new LocalPlaylistItemHolder(localItemBuilder, parent);
|
||||
case LOCAL_PLAYLIST_GRID_HOLDER_TYPE ->
|
||||
new LocalPlaylistGridItemHolder(localItemBuilder, parent);
|
||||
case LOCAL_PLAYLIST_CARD_HOLDER_TYPE ->
|
||||
new LocalPlaylistCardItemHolder(localItemBuilder, parent);
|
||||
case LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE ->
|
||||
new LocalBookmarkPlaylistItemHolder(localItemBuilder, parent);
|
||||
case REMOTE_PLAYLIST_HOLDER_TYPE ->
|
||||
new RemotePlaylistItemHolder(localItemBuilder, parent);
|
||||
case REMOTE_PLAYLIST_GRID_HOLDER_TYPE ->
|
||||
new RemotePlaylistGridItemHolder(localItemBuilder, parent);
|
||||
case REMOTE_PLAYLIST_CARD_HOLDER_TYPE ->
|
||||
new RemotePlaylistCardItemHolder(localItemBuilder, parent);
|
||||
case REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE ->
|
||||
new RemoteBookmarkPlaylistItemHolder(localItemBuilder, parent);
|
||||
case STREAM_PLAYLIST_HOLDER_TYPE ->
|
||||
new LocalPlaylistStreamItemHolder(localItemBuilder, parent);
|
||||
case STREAM_PLAYLIST_GRID_HOLDER_TYPE ->
|
||||
new LocalPlaylistStreamGridItemHolder(localItemBuilder, parent);
|
||||
case STREAM_PLAYLIST_CARD_HOLDER_TYPE ->
|
||||
new LocalPlaylistStreamCardItemHolder(localItemBuilder, parent);
|
||||
default -> {
|
||||
Log.e(TAG, "No view type has been considered for holder: [" + type + "]");
|
||||
return new FallbackViewHolder(new View(parent.getContext()));
|
||||
}
|
||||
yield new FallbackViewHolder(new View(parent.getContext()));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@SuppressWarnings("FinalParameters")
|
||||
|
||||
@@ -16,6 +16,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;
|
||||
@@ -39,6 +40,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;
|
||||
@@ -124,6 +127,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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.schabi.newpipe.local.feed
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
@@ -10,6 +9,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import java.time.LocalDate
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import org.schabi.newpipe.MainActivity.DEBUG
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.database.feed.model.FeedEntity
|
||||
@@ -110,20 +110,9 @@ class FeedDatabaseManager(context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
fun removeOrphansOrOlderStreams(oldestAllowedDate: OffsetDateTime = FEED_OLDEST_ALLOWED_DATE) {
|
||||
suspend fun removeOrphansOrOlderStreams(oldestAllowedDate: OffsetDateTime = FEED_OLDEST_ALLOWED_DATE) {
|
||||
feedTable.unlinkStreamsOlderThan(oldestAllowedDate)
|
||||
streamTable.deleteOrphans()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
feedTable.deleteAll()
|
||||
val deletedOrphans = streamTable.deleteOrphans()
|
||||
if (DEBUG) {
|
||||
Log.d(
|
||||
this::class.java.simpleName,
|
||||
"clear() → streamTable.deleteOrphans() → $deletedOrphans"
|
||||
)
|
||||
}
|
||||
streamTable.deleteOrphans().await()
|
||||
}
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -76,6 +76,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
|
||||
@@ -135,6 +136,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)
|
||||
@@ -204,6 +206,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
// Menu
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
|
||||
@@ -214,6 +217,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())
|
||||
@@ -269,6 +273,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
.show()
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onDestroyOptionsMenu() {
|
||||
super.onDestroyOptionsMenu()
|
||||
if (
|
||||
|
||||
@@ -76,6 +76,7 @@ class NotificationHelper(val context: Context) {
|
||||
|
||||
val avatarIcon =
|
||||
CoilHelper.loadBitmapBlocking(context, data.avatarUrl, R.drawable.ic_newpipe_triangle_white)
|
||||
|
||||
summaryBuilder.setLargeIcon(avatarIcon)
|
||||
|
||||
// Show individual stream notifications, set channel icon only if there is actually one
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.preference.PreferenceManager
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Notification
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
@@ -15,6 +14,8 @@ import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.rx3.rxCompletable
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
@@ -262,7 +263,7 @@ class FeedLoadManager(private val context: Context) {
|
||||
* Remove streams from the feed which are older than [FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE].
|
||||
* Remove streams from the database which are not linked / used by any table.
|
||||
*/
|
||||
private fun postProcessFeed() = Completable.fromRunnable {
|
||||
private fun postProcessFeed() = rxCompletable(Dispatchers.IO) {
|
||||
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message))
|
||||
feedDatabaseManager.removeOrphansOrOlderStreams()
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.schabi.newpipe.local.history
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.compose.content
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.error.ErrorInfo
|
||||
import org.schabi.newpipe.error.ErrorUtil.Companion.openActivity
|
||||
import org.schabi.newpipe.error.UserAction
|
||||
import org.schabi.newpipe.ui.screens.HistoryScreen
|
||||
import org.schabi.newpipe.ui.theme.AppTheme
|
||||
|
||||
class HistoryFragment : Fragment() {
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
) = content {
|
||||
AppTheme {
|
||||
Surface {
|
||||
HistoryScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(
|
||||
view: View,
|
||||
savedInstanceState: Bundle?
|
||||
) {
|
||||
val context = requireActivity()
|
||||
(context as? AppCompatActivity)?.supportActionBar?.setTitle(R.string.title_activity_history)
|
||||
|
||||
val recordManager = HistoryRecordManager(context)
|
||||
context.addMenuProvider(
|
||||
object : MenuProvider {
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.menu_history, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_history_clear -> {
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle(R.string.delete_view_history_alert)
|
||||
.setNegativeButton(R.string.cancel) { dialog, which -> dialog.dismiss() }
|
||||
.setPositiveButton(R.string.delete) { dialog, which ->
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
launch(getExceptionHandler("Delete playback states")) {
|
||||
recordManager.deleteCompleteStreamStateHistory().await()
|
||||
Toast
|
||||
.makeText(context, R.string.watch_history_states_deleted, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
|
||||
launch(getExceptionHandler("Delete watch history")) {
|
||||
recordManager.deleteWholeStreamHistory().await()
|
||||
Toast.makeText(context, R.string.watch_history_deleted, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
|
||||
launch(getExceptionHandler("Clear orphaned records")) {
|
||||
recordManager.removeOrphanedRecords().await()
|
||||
}
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
},
|
||||
viewLifecycleOwner
|
||||
)
|
||||
}
|
||||
|
||||
private fun getExceptionHandler(action: String) = CoroutineExceptionHandler { _, throwable ->
|
||||
openActivity(requireContext(), ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, action))
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -157,30 +151,22 @@ public class HistoryRecordManager {
|
||||
}
|
||||
|
||||
public Completable deleteStreamHistoryAndState(final long streamId) {
|
||||
return Completable.fromAction(() -> {
|
||||
streamStateTable.deleteState(streamId);
|
||||
streamHistoryTable.deleteStreamHistory(streamId);
|
||||
}).subscribeOn(Schedulers.io());
|
||||
return streamStateTable.deleteState(streamId)
|
||||
.andThen(streamHistoryTable.deleteStreamHistory(streamId));
|
||||
}
|
||||
|
||||
public Single<Integer> deleteWholeStreamHistory() {
|
||||
return Single.fromCallable(streamHistoryTable::deleteAll)
|
||||
.subscribeOn(Schedulers.io());
|
||||
public Completable deleteWholeStreamHistory() {
|
||||
return streamHistoryTable.deleteAll().subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Single<Integer> deleteCompleteStreamStateHistory() {
|
||||
return Single.fromCallable(streamStateTable::deleteAll)
|
||||
.subscribeOn(Schedulers.io());
|
||||
public Completable deleteCompleteStreamStateHistory() {
|
||||
return streamStateTable.deleteAll().subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Flowable<List<StreamHistoryEntry>> getStreamHistorySortedById() {
|
||||
return streamHistoryTable.getHistorySortedById().subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Flowable<List<StreamStatisticsEntry>> getStreamStatistics() {
|
||||
return streamHistoryTable.getStatistics().subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
private boolean isStreamHistoryEnabled() {
|
||||
return sharedPreferences.getBoolean(streamHistoryKey, false);
|
||||
}
|
||||
@@ -221,7 +207,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 +222,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 +265,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());
|
||||
@@ -311,8 +275,7 @@ public class HistoryRecordManager {
|
||||
// Utility
|
||||
///////////////////////////////////////////////////////
|
||||
|
||||
public Single<Integer> removeOrphanedRecords() {
|
||||
return Single.fromCallable(streamTable::deleteOrphans).subscribeOn(Schedulers.io());
|
||||
public Completable removeOrphanedRecords() {
|
||||
return streamTable.deleteOrphans().subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.schabi.newpipe.local.history
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.map
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.ui.components.items.Stream
|
||||
|
||||
class HistoryViewModel(
|
||||
application: Application,
|
||||
private val savedStateHandle: SavedStateHandle
|
||||
) : AndroidViewModel(application) {
|
||||
private val historyDao = NewPipeDatabase.getInstance(getApplication()).streamHistoryDAO()
|
||||
|
||||
val sortKey = savedStateHandle.getStateFlow(ORDER_KEY, SortKey.LAST_PLAYED)
|
||||
val historyItems = sortKey
|
||||
.flatMapLatest {
|
||||
Pager(PagingConfig(pageSize = 20)) {
|
||||
historyDao.getOrderedHistory(it)
|
||||
}.flow
|
||||
}
|
||||
.map { pagingData -> pagingData.map { Stream(it) } }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
fun updateOrder(sortKey: SortKey) {
|
||||
savedStateHandle[ORDER_KEY] = sortKey
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ORDER_KEY = "order"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.schabi.newpipe.local.history
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.schabi.newpipe.R
|
||||
|
||||
enum class SortKey(@StringRes val title: Int) {
|
||||
LAST_PLAYED(R.string.history_sort_date),
|
||||
MOST_PLAYED(R.string.history_sort_views)
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
package org.schabi.newpipe.local.history;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.os.Parcelable;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.evernote.android.state.State;
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
|
||||
import org.reactivestreams.Subscriber;
|
||||
import org.reactivestreams.Subscription;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.LocalItem;
|
||||
import org.schabi.newpipe.database.stream.StreamStatisticsEntry;
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.databinding.PlaylistControlBinding;
|
||||
import org.schabi.newpipe.databinding.StatisticPlaylistControlBinding;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
|
||||
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
|
||||
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
|
||||
import org.schabi.newpipe.local.BaseLocalListFragment;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
||||
import org.schabi.newpipe.settings.HistorySettingsFragment;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.OnClickGesture;
|
||||
import org.schabi.newpipe.util.PlayButtonHelper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
public class StatisticsPlaylistFragment
|
||||
extends BaseLocalListFragment<List<StreamStatisticsEntry>, Void>
|
||||
implements PlaylistControlViewHolder {
|
||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||
@State
|
||||
Parcelable itemsListState;
|
||||
private StatisticSortMode sortMode = StatisticSortMode.LAST_PLAYED;
|
||||
|
||||
private StatisticPlaylistControlBinding headerBinding;
|
||||
private PlaylistControlBinding playlistControlBinding;
|
||||
|
||||
/* Used for independent events */
|
||||
private Subscription databaseSubscription;
|
||||
private HistoryRecordManager recordManager;
|
||||
|
||||
private List<StreamStatisticsEntry> processResult(final List<StreamStatisticsEntry> results) {
|
||||
final Comparator<StreamStatisticsEntry> comparator;
|
||||
switch (sortMode) {
|
||||
case LAST_PLAYED:
|
||||
comparator = Comparator.comparing(StreamStatisticsEntry::getLatestAccessDate);
|
||||
break;
|
||||
case MOST_PLAYED:
|
||||
comparator = Comparator.comparingLong(StreamStatisticsEntry::getWatchCount);
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
Collections.sort(results, comparator.reversed());
|
||||
return results;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Fragment LifeCycle - Creation
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@Override
|
||||
public void onCreate(final Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
recordManager = new HistoryRecordManager(getContext());
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull final LayoutInflater inflater,
|
||||
@Nullable final ViewGroup container,
|
||||
@Nullable final Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_playlist, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
if (activity != null) {
|
||||
setTitle(activity.getString(R.string.title_activity_history));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
inflater.inflate(R.menu.menu_history, menu);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Fragment LifeCycle - Views
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@Override
|
||||
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
||||
super.initViews(rootView, savedInstanceState);
|
||||
if (!useAsFrontPage) {
|
||||
setTitle(getString(R.string.title_last_played));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Supplier<View> getListHeaderSupplier() {
|
||||
headerBinding = StatisticPlaylistControlBinding.inflate(activity.getLayoutInflater(),
|
||||
itemsList, false);
|
||||
playlistControlBinding = headerBinding.playlistControl;
|
||||
|
||||
return headerBinding::getRoot;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initListeners() {
|
||||
super.initListeners();
|
||||
|
||||
itemListAdapter.setSelectedListener(new OnClickGesture<>() {
|
||||
@Override
|
||||
public void selected(final LocalItem selectedItem) {
|
||||
if (selectedItem instanceof StreamStatisticsEntry) {
|
||||
final StreamEntity item =
|
||||
((StreamStatisticsEntry) selectedItem).getStreamEntity();
|
||||
NavigationHelper.openVideoDetailFragment(requireContext(), getFM(),
|
||||
item.getServiceId(), item.getUrl(), item.getTitle(), null, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void held(final LocalItem selectedItem) {
|
||||
if (selectedItem instanceof StreamStatisticsEntry) {
|
||||
showInfoItemDialog((StreamStatisticsEntry) selectedItem);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(final MenuItem item) {
|
||||
if (item.getItemId() == R.id.action_history_clear) {
|
||||
HistorySettingsFragment
|
||||
.openDeleteWatchHistoryDialog(requireContext(), recordManager, disposables);
|
||||
} else {
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Fragment LifeCycle - Loading
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@Override
|
||||
public void startLoading(final boolean forceLoad) {
|
||||
super.startLoading(forceLoad);
|
||||
recordManager.getStreamStatistics()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(getHistoryObserver());
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Fragment LifeCycle - Destruction
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
itemsListState = Objects.requireNonNull(itemsList.getLayoutManager()).onSaveInstanceState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
|
||||
if (itemListAdapter != null) {
|
||||
itemListAdapter.unsetSelectedListener();
|
||||
}
|
||||
|
||||
headerBinding = null;
|
||||
playlistControlBinding = null;
|
||||
|
||||
if (databaseSubscription != null) {
|
||||
databaseSubscription.cancel();
|
||||
}
|
||||
databaseSubscription = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
recordManager = null;
|
||||
itemsListState = null;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Statistics Loader
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private Subscriber<List<StreamStatisticsEntry>> getHistoryObserver() {
|
||||
return new Subscriber<List<StreamStatisticsEntry>>() {
|
||||
@Override
|
||||
public void onSubscribe(final Subscription s) {
|
||||
showLoading();
|
||||
|
||||
if (databaseSubscription != null) {
|
||||
databaseSubscription.cancel();
|
||||
}
|
||||
databaseSubscription = s;
|
||||
databaseSubscription.request(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(final List<StreamStatisticsEntry> streams) {
|
||||
handleResult(streams);
|
||||
if (databaseSubscription != null) {
|
||||
databaseSubscription.request(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(final Throwable exception) {
|
||||
showError(
|
||||
new ErrorInfo(exception, UserAction.SOMETHING_ELSE, "History Statistics"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleResult(@NonNull final List<StreamStatisticsEntry> result) {
|
||||
super.handleResult(result);
|
||||
if (itemListAdapter == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
playlistControlBinding.getRoot().setVisibility(View.VISIBLE);
|
||||
|
||||
itemListAdapter.clearStreamItemList();
|
||||
|
||||
if (result.isEmpty()) {
|
||||
showEmptyState();
|
||||
return;
|
||||
}
|
||||
|
||||
itemListAdapter.addItems(processResult(result));
|
||||
if (itemsListState != null && itemsList.getLayoutManager() != null) {
|
||||
itemsList.getLayoutManager().onRestoreInstanceState(itemsListState);
|
||||
itemsListState = null;
|
||||
}
|
||||
|
||||
PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this);
|
||||
|
||||
headerBinding.sortButton.setOnClickListener(view -> toggleSortMode());
|
||||
|
||||
hideLoading();
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Fragment Error Handling
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@Override
|
||||
protected void resetFragment() {
|
||||
super.resetFragment();
|
||||
if (databaseSubscription != null) {
|
||||
databaseSubscription.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Utils
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private void toggleSortMode() {
|
||||
if (sortMode == StatisticSortMode.LAST_PLAYED) {
|
||||
sortMode = StatisticSortMode.MOST_PLAYED;
|
||||
setTitle(getString(R.string.title_most_played));
|
||||
headerBinding.sortButtonIcon.setImageResource(R.drawable.ic_history);
|
||||
headerBinding.sortButtonText.setText(R.string.title_last_played);
|
||||
} else {
|
||||
sortMode = StatisticSortMode.LAST_PLAYED;
|
||||
setTitle(getString(R.string.title_last_played));
|
||||
headerBinding.sortButtonIcon.setImageResource(
|
||||
R.drawable.ic_filter_list);
|
||||
headerBinding.sortButtonText.setText(R.string.title_most_played);
|
||||
}
|
||||
startLoading(true);
|
||||
}
|
||||
|
||||
private PlayQueue getPlayQueueStartingAt(final StreamStatisticsEntry infoItem) {
|
||||
return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0));
|
||||
}
|
||||
|
||||
private void showInfoItemDialog(final StreamStatisticsEntry item) {
|
||||
final Context context = getContext();
|
||||
final StreamInfoItem infoItem = item.toStreamInfoItem();
|
||||
|
||||
try {
|
||||
final InfoItemDialog.Builder dialogBuilder =
|
||||
new InfoItemDialog.Builder(getActivity(), context, this, infoItem);
|
||||
|
||||
// set entries in the middle; the others are added automatically
|
||||
dialogBuilder
|
||||
.addEntry(StreamDialogDefaultEntry.DELETE)
|
||||
.setAction(
|
||||
StreamDialogDefaultEntry.DELETE,
|
||||
(f, i) -> deleteEntry(
|
||||
Math.max(itemListAdapter.getItemsList().indexOf(item), 0)))
|
||||
.create()
|
||||
.show();
|
||||
} catch (final IllegalArgumentException e) {
|
||||
InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteEntry(final int index) {
|
||||
final LocalItem infoItem = itemListAdapter.getItemsList().get(index);
|
||||
if (infoItem instanceof StreamStatisticsEntry) {
|
||||
final StreamStatisticsEntry entry = (StreamStatisticsEntry) infoItem;
|
||||
final Disposable onDelete = recordManager
|
||||
.deleteStreamHistoryAndState(entry.getStreamId())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
() -> {
|
||||
if (getView() != null) {
|
||||
Snackbar.make(getView(), R.string.one_item_deleted,
|
||||
Snackbar.LENGTH_SHORT).show();
|
||||
} else {
|
||||
Toast.makeText(getContext(),
|
||||
R.string.one_item_deleted,
|
||||
Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
},
|
||||
throwable -> showSnackBarError(new ErrorInfo(throwable,
|
||||
UserAction.DELETE_FROM_HISTORY, "Deleting item")));
|
||||
|
||||
disposables.add(onDelete);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public PlayQueue getPlayQueue() {
|
||||
return getPlayQueue(0);
|
||||
}
|
||||
|
||||
private PlayQueue getPlayQueue(final int index) {
|
||||
if (itemListAdapter == null) {
|
||||
return new SinglePlayQueue(Collections.emptyList(), 0);
|
||||
}
|
||||
|
||||
final List<LocalItem> infoItems = itemListAdapter.getItemsList();
|
||||
final List<StreamInfoItem> streamInfoItems = new ArrayList<>(infoItems.size());
|
||||
for (final LocalItem item : infoItems) {
|
||||
if (item instanceof StreamStatisticsEntry) {
|
||||
streamInfoItems.add(((StreamStatisticsEntry) item).toStreamInfoItem());
|
||||
}
|
||||
}
|
||||
return new SinglePlayQueue(streamInfoItems, index);
|
||||
}
|
||||
|
||||
private enum StatisticSortMode {
|
||||
LAST_PLAYED,
|
||||
MOST_PLAYED,
|
||||
}
|
||||
}
|
||||
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
package org.schabi.newpipe.local.holder;
|
||||
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.local.LocalItemBuilder;
|
||||
|
||||
public class LocalStatisticStreamCardItemHolder extends LocalStatisticStreamItemHolder {
|
||||
public LocalStatisticStreamCardItemHolder(final LocalItemBuilder infoItemBuilder,
|
||||
final ViewGroup parent) {
|
||||
super(infoItemBuilder, R.layout.list_stream_card_item, parent);
|
||||
}
|
||||
}
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
package org.schabi.newpipe.local.holder;
|
||||
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.local.LocalItemBuilder;
|
||||
|
||||
public class LocalStatisticStreamGridItemHolder extends LocalStatisticStreamItemHolder {
|
||||
public LocalStatisticStreamGridItemHolder(final LocalItemBuilder infoItemBuilder,
|
||||
final ViewGroup parent) {
|
||||
super(infoItemBuilder, R.layout.list_stream_grid_item, parent);
|
||||
}
|
||||
}
|
||||
-161
@@ -1,161 +0,0 @@
|
||||
package org.schabi.newpipe.local.holder;
|
||||
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.LocalItem;
|
||||
import org.schabi.newpipe.database.stream.StreamStatisticsEntry;
|
||||
import org.schabi.newpipe.ktx.ViewUtils;
|
||||
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.ServiceHelper;
|
||||
import org.schabi.newpipe.util.image.CoilHelper;
|
||||
import org.schabi.newpipe.views.AnimatedProgressBar;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/*
|
||||
* Created by Christian Schabesberger on 01.08.16.
|
||||
* <p>
|
||||
* Copyright (C) Christian Schabesberger 2016 <chris.schabesberger@mailbox.org>
|
||||
* StreamInfoItemHolder.java is part of NewPipe.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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 LocalStatisticStreamItemHolder extends LocalItemHolder {
|
||||
public final ImageView itemThumbnailView;
|
||||
public final TextView itemVideoTitleView;
|
||||
public final TextView itemUploaderView;
|
||||
public final TextView itemDurationView;
|
||||
@Nullable
|
||||
public final TextView itemAdditionalDetails;
|
||||
private final AnimatedProgressBar itemProgressView;
|
||||
|
||||
public LocalStatisticStreamItemHolder(final LocalItemBuilder itemBuilder,
|
||||
final ViewGroup parent) {
|
||||
this(itemBuilder, R.layout.list_stream_item, parent);
|
||||
}
|
||||
|
||||
LocalStatisticStreamItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId,
|
||||
final ViewGroup parent) {
|
||||
super(infoItemBuilder, layoutId, parent);
|
||||
|
||||
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
|
||||
itemVideoTitleView = itemView.findViewById(R.id.itemVideoTitleView);
|
||||
itemUploaderView = itemView.findViewById(R.id.itemUploaderView);
|
||||
itemDurationView = itemView.findViewById(R.id.itemDurationView);
|
||||
itemAdditionalDetails = itemView.findViewById(R.id.itemAdditionalDetails);
|
||||
itemProgressView = itemView.findViewById(R.id.itemProgressView);
|
||||
}
|
||||
|
||||
private String getStreamInfoDetailLine(final StreamStatisticsEntry entry,
|
||||
final DateTimeFormatter dateTimeFormatter) {
|
||||
return Localization.concatenateStrings(
|
||||
// watchCount
|
||||
Localization.shortViewCount(itemBuilder.getContext(), entry.getWatchCount()),
|
||||
dateTimeFormatter.format(entry.getLatestAccessDate()),
|
||||
// serviceName
|
||||
ServiceHelper.getNameOfServiceById(entry.getStreamEntity().getServiceId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateFromItem(final LocalItem localItem,
|
||||
final HistoryRecordManager historyRecordManager,
|
||||
final DateTimeFormatter dateTimeFormatter) {
|
||||
if (!(localItem instanceof StreamStatisticsEntry)) {
|
||||
return;
|
||||
}
|
||||
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
|
||||
|
||||
itemVideoTitleView.setText(item.getStreamEntity().getTitle());
|
||||
itemUploaderView.setText(item.getStreamEntity().getUploader());
|
||||
|
||||
if (item.getStreamEntity().getDuration() > 0) {
|
||||
itemDurationView.
|
||||
setText(Localization.getDurationString(item.getStreamEntity().getDuration()));
|
||||
itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(),
|
||||
R.color.duration_background_color));
|
||||
itemDurationView.setVisibility(View.VISIBLE);
|
||||
|
||||
if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())
|
||||
&& item.getProgressMillis() > 0) {
|
||||
itemProgressView.setVisibility(View.VISIBLE);
|
||||
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
|
||||
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
|
||||
.toSeconds(item.getProgressMillis()));
|
||||
} else {
|
||||
itemProgressView.setVisibility(View.GONE);
|
||||
}
|
||||
} else {
|
||||
itemDurationView.setVisibility(View.GONE);
|
||||
itemProgressView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (itemAdditionalDetails != null) {
|
||||
itemAdditionalDetails.setText(getStreamInfoDetailLine(item, dateTimeFormatter));
|
||||
}
|
||||
|
||||
// Default thumbnail is shown on error, while loading and if the url is empty
|
||||
CoilHelper.INSTANCE.loadThumbnail(itemThumbnailView,
|
||||
item.getStreamEntity().getThumbnailUrl());
|
||||
|
||||
itemView.setOnClickListener(view -> {
|
||||
if (itemBuilder.getOnItemSelectedListener() != null) {
|
||||
itemBuilder.getOnItemSelectedListener().selected(item);
|
||||
}
|
||||
});
|
||||
|
||||
itemView.setLongClickable(true);
|
||||
itemView.setOnLongClickListener(view -> {
|
||||
if (itemBuilder.getOnItemSelectedListener() != null) {
|
||||
itemBuilder.getOnItemSelectedListener().held(item);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateState(final LocalItem localItem,
|
||||
final HistoryRecordManager historyRecordManager) {
|
||||
if (!(localItem instanceof StreamStatisticsEntry)) {
|
||||
return;
|
||||
}
|
||||
final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem;
|
||||
|
||||
if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())
|
||||
&& item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) {
|
||||
itemProgressView.setMax((int) item.getStreamEntity().getDuration());
|
||||
if (itemProgressView.getVisibility() == View.VISIBLE) {
|
||||
itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS
|
||||
.toSeconds(item.getProgressMillis()));
|
||||
} else {
|
||||
itemProgressView.setProgress((int) TimeUnit.MILLISECONDS
|
||||
.toSeconds(item.getProgressMillis()));
|
||||
ViewUtils.animate(itemProgressView, true, 500);
|
||||
}
|
||||
} else if (itemProgressView.getVisibility() == View.VISIBLE) {
|
||||
ViewUtils.animate(itemProgressView, false, 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ 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.ui.emptystate.setEmptyStateComposable
|
||||
import org.schabi.newpipe.util.NavigationHelper
|
||||
import org.schabi.newpipe.util.OnClickGesture
|
||||
import org.schabi.newpipe.util.ServiceHelper
|
||||
@@ -113,6 +114,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||
// Menu
|
||||
// ////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
|
||||
@@ -199,6 +201,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) {
|
||||
|
||||
@@ -50,16 +50,13 @@ class SubscriptionManager(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun upsertAll(infoList: List<Pair<ChannelInfo, ChannelTabInfo?>>) {
|
||||
fun upsertAll(infoList: List<Pair<ChannelInfo, ChannelTabInfo>>) {
|
||||
val listEntities = infoList.map { SubscriptionEntity.from(it.first) }
|
||||
subscriptionTable.upsertAll(listEntities)
|
||||
|
||||
database.runInTransaction {
|
||||
infoList.forEachIndexed { index, info ->
|
||||
// There may be no tabs on the channel to refresh the feed from
|
||||
if (info.second == null) return@forEachIndexed
|
||||
|
||||
val streams = info.second!!.relatedItems.filterIsInstance<StreamInfoItem>()
|
||||
val streams = info.second.relatedItems.filterIsInstance<StreamInfoItem>()
|
||||
feedDatabaseManager.upsertAll(listEntities[index].uid, streams)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,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()
|
||||
|
||||
+8
-4
@@ -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)
|
||||
}
|
||||
|
||||
+8
-3
@@ -19,6 +19,7 @@ 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
|
||||
|
||||
@@ -51,7 +52,9 @@ class SubscriptionExportWorker(
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "Exported $qty subscriptions")
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.i(TAG, "Exported $qty subscriptions")
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast
|
||||
@@ -60,8 +63,10 @@ class SubscriptionExportWorker(
|
||||
}
|
||||
|
||||
Result.success()
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Error while exporting subscriptions", exception)
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.e(TAG, "Error while exporting subscriptions", e)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast
|
||||
|
||||
+100
-139
@@ -1,18 +1,12 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2018-2025 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-FileCopyrightText: 2026 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.local.subscription.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
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 android.widget.Toast.LENGTH_SHORT
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.work.CoroutineWorker
|
||||
@@ -22,118 +16,101 @@ import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.chunked
|
||||
import kotlinx.coroutines.flow.flatMapMerge
|
||||
import kotlinx.coroutines.flow.flow
|
||||
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.streams.io.SharpInputStream
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
|
||||
class SubscriptionImportWorker(
|
||||
private val context: Context,
|
||||
private val params: WorkerParameters
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
private lateinit var subscriptions: List<SubscriptionItem>
|
||||
|
||||
private val subscriptionManager = SubscriptionManager(context)
|
||||
private val mutex = Mutex()
|
||||
private val size: Int
|
||||
get() = subscriptions.size
|
||||
|
||||
appContext: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
// This is needed for API levels < 31 (Android S).
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
return createForegroundInfo(title = context.getString(R.string.import_ongoing))
|
||||
return createForegroundInfo(applicationContext.getString(R.string.import_ongoing), null, 0, 0)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override suspend fun doWork(): Result {
|
||||
subscriptions = try {
|
||||
loadSubscriptionsFromInput(SubscriptionImportInput.fromData(inputData))
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Error while loading subscriptions from path", exception)
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, R.string.subscriptions_import_unsuccessful, LENGTH_SHORT)
|
||||
.show()
|
||||
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()
|
||||
}
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val title = context.resources.getQuantityString(R.plurals.import_subscriptions, size, size)
|
||||
setForeground(createForegroundInfo(title = title))
|
||||
val mutex = Mutex()
|
||||
var index = 1
|
||||
val qty = subscriptions.size
|
||||
var title =
|
||||
applicationContext.resources.getQuantityString(R.plurals.load_subscriptions, qty, qty)
|
||||
|
||||
try {
|
||||
var index = 1
|
||||
subscriptions.asFlow()
|
||||
.flatMapMerge(concurrency = PARALLEL_EXTRACTIONS) { subscription ->
|
||||
flow {
|
||||
runCatching {
|
||||
ExtractorHelper.getChannelInfo(
|
||||
subscription.serviceId,
|
||||
subscription.url,
|
||||
true
|
||||
).await()
|
||||
}.mapCatching { channelInfo ->
|
||||
val channelTab = when {
|
||||
channelInfo.tabs.isNotEmpty() -> {
|
||||
ExtractorHelper.getChannelTab(
|
||||
subscription.serviceId,
|
||||
channelInfo.tabs[0],
|
||||
true
|
||||
).await()
|
||||
}
|
||||
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()
|
||||
|
||||
else -> {
|
||||
Log.i(TAG, "No channel tabs available for: ${channelInfo.url}")
|
||||
null
|
||||
}
|
||||
val currentIndex = mutex.withLock { index++ }
|
||||
setForeground(createForegroundInfo(title, channelInfo.name, currentIndex, qty))
|
||||
|
||||
channelInfo to channelTab
|
||||
}
|
||||
Pair(channelInfo, channelTab)
|
||||
}.also {
|
||||
mutex.withLock { index++ }
|
||||
}.onFailure { exception ->
|
||||
Log.e(TAG, "Failed to fetch subscription", exception)
|
||||
}.onSuccess { (channelInfo, channelTab) ->
|
||||
setForeground(createForegroundInfo(title, channelInfo.name, index))
|
||||
emit(channelInfo to channelTab)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
.chunked(BUFFER_COUNT_BEFORE_INSERT)
|
||||
.collect { chunk ->
|
||||
subscriptionManager.upsertAll(chunk)
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.e(TAG, "Error while loading subscription data", e)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Error while processing subscription data", exception)
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, R.string.subscriptions_import_unsuccessful, LENGTH_SHORT)
|
||||
.show()
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(applicationContext, R.string.subscriptions_import_unsuccessful, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
return Result.failure()
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
setForeground(createForegroundInfo(title = title, progress = size))
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(applicationContext, R.string.import_complete_toast, LENGTH_SHORT)
|
||||
Toast.makeText(applicationContext, R.string.import_complete_toast, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private suspend fun loadSubscriptionsFromInput(
|
||||
input: SubscriptionImportInput
|
||||
): List<SubscriptionItem> {
|
||||
private suspend fun loadSubscriptionsFromInput(input: SubscriptionImportInput): List<SubscriptionItem> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
when (input) {
|
||||
is SubscriptionImportInput.ChannelUrlMode ->
|
||||
@@ -142,18 +119,16 @@ class SubscriptionImportWorker(
|
||||
.map { SubscriptionItem(it.serviceId, it.url, it.name) }
|
||||
|
||||
is SubscriptionImportInput.InputStreamMode ->
|
||||
StoredFileHelper(context, input.url.toUri(), DEFAULT_MIME).let { fileHelper ->
|
||||
SharpInputStream(fileHelper.getStream()).use { stream ->
|
||||
val contentType = getInputStreamContentType(fileHelper)
|
||||
|
||||
NewPipe.getService(input.serviceId).subscriptionExtractor
|
||||
.fromInputStream(stream, contentType)
|
||||
.map { SubscriptionItem(it.serviceId, it.url, it.name) }
|
||||
}
|
||||
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 ->
|
||||
context.contentResolver.openInputStream(input.url.toUri())?.use {
|
||||
applicationContext.contentResolver.openInputStream(input.url.toUri())?.use {
|
||||
ImportExportJsonHelper.readFrom(it)
|
||||
}
|
||||
} ?: emptyList()
|
||||
@@ -162,36 +137,35 @@ class SubscriptionImportWorker(
|
||||
|
||||
private fun createForegroundInfo(
|
||||
title: String,
|
||||
text: String? = null,
|
||||
progress: Int = 0
|
||||
text: String?,
|
||||
currentProgress: Int,
|
||||
maxProgress: Int
|
||||
): ForegroundInfo {
|
||||
val maxProgress = subscriptions.size
|
||||
val notification = NotificationCompat
|
||||
.Builder(context, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setOngoing(true)
|
||||
.setProgress(maxProgress, progress, progress == 0)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.addAction(
|
||||
R.drawable.ic_close,
|
||||
context.getString(R.string.cancel),
|
||||
WorkManager.getInstance(context).createCancelPendingIntent(id)
|
||||
).apply {
|
||||
if (progress > 0 && maxProgress > 0) {
|
||||
val progressText = "$progress/$maxProgress"
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
setSubText(progressText)
|
||||
} else {
|
||||
setContentInfo(progressText)
|
||||
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 = when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
else -> 0
|
||||
}
|
||||
}.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)
|
||||
}
|
||||
@@ -207,20 +181,6 @@ class SubscriptionImportWorker(
|
||||
private const val BUFFER_COUNT_BEFORE_INSERT = 50
|
||||
|
||||
const val WORK_NAME = "SubscriptionImportWorker"
|
||||
|
||||
internal fun getInputStreamContentType(fileHelper: StoredFileHelper): String {
|
||||
val contentType = fileHelper.getType()
|
||||
if (!contentType.isNullOrEmpty() && contentType != DEFAULT_MIME) {
|
||||
return contentType
|
||||
}
|
||||
|
||||
val fileName = fileHelper.getName() ?: return DEFAULT_MIME
|
||||
val pointIndex = fileName.lastIndexOf('.')
|
||||
return when {
|
||||
pointIndex == -1 || pointIndex >= fileName.length - 1 -> DEFAULT_MIME
|
||||
else -> fileName.substring(pointIndex + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +210,8 @@ sealed class SubscriptionImportInput : Parcelable {
|
||||
private const val PREVIOUS_EXPORT_MODE = 2
|
||||
|
||||
fun fromData(data: Data): SubscriptionImportInput {
|
||||
when (val mode = data.getInt("mode", PREVIOUS_EXPORT_MODE)) {
|
||||
val mode = data.getInt("mode", PREVIOUS_EXPORT_MODE)
|
||||
when (mode) {
|
||||
CHANNEL_URL_MODE -> {
|
||||
val serviceId = data.getInt("service_id", -1)
|
||||
if (serviceId == -1) {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.navigation
|
||||
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.core.content.IntentCompat
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import org.schabi.newpipe.ComposeActivity
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.error.ErrorInfo
|
||||
import org.schabi.newpipe.error.ErrorReportHelper
|
||||
import org.schabi.newpipe.ui.components.common.ScaffoldWithToolbar
|
||||
import org.schabi.newpipe.ui.screens.AboutScreen
|
||||
import org.schabi.newpipe.ui.screens.ErrorReportEvent
|
||||
import org.schabi.newpipe.ui.screens.ErrorReportScreen
|
||||
import org.schabi.newpipe.ui.screens.settings.debug.DebugScreen
|
||||
import org.schabi.newpipe.ui.screens.settings.home.SettingsHomeScreen
|
||||
|
||||
/**
|
||||
* Top-level navigation display for all Compose screens in the app.
|
||||
* @param startDestination the initial screen to display, resolved from the launching Intent.
|
||||
*/
|
||||
@Composable
|
||||
fun NavDisplay(startDestination: NavKey) {
|
||||
val backstack = rememberNavBackStack(startDestination)
|
||||
val context = LocalContext.current
|
||||
|
||||
// TODO: Drop this logic once everything is in Compose
|
||||
val activity = LocalActivity.current
|
||||
|
||||
fun onNavigateUp() {
|
||||
if (backstack.size > 1) {
|
||||
backstack.removeLastOrNull()
|
||||
} else {
|
||||
activity?.finish()
|
||||
}
|
||||
}
|
||||
|
||||
NavDisplay(
|
||||
backStack = backstack,
|
||||
onBack = ::onNavigateUp,
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator()
|
||||
),
|
||||
entryProvider = entryProvider {
|
||||
// About
|
||||
entry<Screen.About> {
|
||||
ScaffoldWithToolbar(
|
||||
title = stringResource(R.string.title_activity_about),
|
||||
onBackClick = ::onNavigateUp
|
||||
) { padding ->
|
||||
AboutScreen(padding)
|
||||
}
|
||||
}
|
||||
|
||||
// Error Report
|
||||
entry<Screen.Error> {
|
||||
val errorInfo = remember {
|
||||
IntentCompat.getParcelableExtra(
|
||||
activity!!.intent,
|
||||
ComposeActivity.EXTRA_ERROR_INFO,
|
||||
ErrorInfo::class.java
|
||||
)!!
|
||||
}
|
||||
|
||||
ErrorReportScreen(
|
||||
errorInfo = errorInfo,
|
||||
onEvent = { event ->
|
||||
when (event) {
|
||||
is ErrorReportEvent.ReportViaEmail ->
|
||||
ErrorReportHelper.sendErrorEmail(context, errorInfo, event.comment)
|
||||
|
||||
is ErrorReportEvent.CopyForGitHub ->
|
||||
ErrorReportHelper.copyForGitHub(context, errorInfo, event.comment)
|
||||
|
||||
is ErrorReportEvent.ReportOnGitHub ->
|
||||
ErrorReportHelper.openGitHubIssues(context)
|
||||
|
||||
is ErrorReportEvent.ReadPrivacyPolicy ->
|
||||
ErrorReportHelper.openPrivacyPolicy(context)
|
||||
|
||||
is ErrorReportEvent.ShareError ->
|
||||
ErrorReportHelper.shareError(context, errorInfo, event.comment)
|
||||
|
||||
is ErrorReportEvent.NavigateUp ->
|
||||
onNavigateUp()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Settings
|
||||
entry<Screen.Settings.Home> {
|
||||
SettingsHomeScreen(
|
||||
onNavigate = { screen -> backstack.add(screen) },
|
||||
onBackClick = ::onNavigateUp
|
||||
)
|
||||
}
|
||||
|
||||
entry<Screen.Settings.Player> {
|
||||
Text(stringResource(id = R.string.settings_category_player_title))
|
||||
}
|
||||
|
||||
entry<Screen.Settings.Behaviour> {
|
||||
Text(stringResource(id = R.string.settings_category_player_behavior_title))
|
||||
}
|
||||
|
||||
entry<Screen.Settings.Download> {
|
||||
Text(stringResource(id = R.string.settings_category_downloads_title))
|
||||
}
|
||||
|
||||
entry<Screen.Settings.LookFeel> {
|
||||
Text(stringResource(id = R.string.settings_category_look_and_feel_title))
|
||||
}
|
||||
|
||||
entry<Screen.Settings.HistoryCache> {
|
||||
Text(stringResource(id = R.string.settings_category_history_title))
|
||||
}
|
||||
|
||||
entry<Screen.Settings.Content> {
|
||||
Text(stringResource(id = R.string.settings_category_content_title))
|
||||
}
|
||||
|
||||
entry<Screen.Settings.Feed> {
|
||||
Text(stringResource(id = R.string.settings_category_feed_title))
|
||||
}
|
||||
|
||||
entry<Screen.Settings.Services> {
|
||||
Text(stringResource(id = R.string.settings_category_services_title))
|
||||
}
|
||||
|
||||
entry<Screen.Settings.Language> {
|
||||
Text(stringResource(id = R.string.settings_category_language_title))
|
||||
}
|
||||
|
||||
entry<Screen.Settings.BackupRestore> {
|
||||
Text(stringResource(id = R.string.settings_category_backup_restore_title))
|
||||
}
|
||||
|
||||
entry<Screen.Settings.Updates> {
|
||||
Text(stringResource(id = R.string.settings_category_updates_title))
|
||||
}
|
||||
|
||||
entry<Screen.Settings.Debug> {
|
||||
DebugScreen(onBackClick = { backstack.removeLastOrNull() })
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2017-2026 NewPipe contributors <https://newpipe.net>
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.navigation
|
||||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed interface Screen : NavKey {
|
||||
|
||||
@Serializable
|
||||
data object About : Screen
|
||||
|
||||
@Serializable
|
||||
data object Error : Screen
|
||||
|
||||
sealed interface Settings : Screen {
|
||||
@Serializable
|
||||
data object Home : Settings
|
||||
|
||||
@Serializable
|
||||
data object Player : Settings
|
||||
|
||||
@Serializable
|
||||
data object Behaviour : Settings
|
||||
|
||||
@Serializable
|
||||
data object Download : Settings
|
||||
|
||||
@Serializable
|
||||
data object LookFeel : Settings
|
||||
|
||||
@Serializable
|
||||
data object HistoryCache : Settings
|
||||
|
||||
@Serializable
|
||||
data object Content : Settings
|
||||
|
||||
@Serializable
|
||||
data object Feed : Settings
|
||||
|
||||
@Serializable
|
||||
data object Services : Settings
|
||||
|
||||
@Serializable
|
||||
data object Language : Settings
|
||||
|
||||
@Serializable
|
||||
data object BackupRestore : Settings
|
||||
|
||||
@Serializable
|
||||
data object Updates : Settings
|
||||
|
||||
@Serializable
|
||||
data object Debug : Settings
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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> {
|
||||
return try {
|
||||
// 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)
|
||||
}
|
||||
|
||||
LoadResult.Page(info.items, null, info.nextPage)
|
||||
} catch (e: Exception) {
|
||||
// Catches UnknownHostException (offline) or any extraction errors
|
||||
// and tells Paging3 to show the error state in the UI.
|
||||
LoadResult.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<Page, CommentsInfoItem>) = null
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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 {
|
||||
return try {
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
CommentsInfo.getMoreItems(service, commentInfo.url, params.key)
|
||||
}
|
||||
LoadResult.Page(info.items, null, info.nextPage)
|
||||
} catch (exception: Exception) {
|
||||
LoadResult.Error(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,7 +45,6 @@ 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;
|
||||
@@ -141,6 +140,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();
|
||||
@@ -408,12 +411,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()
|
||||
@@ -429,12 +431,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;
|
||||
}
|
||||
@@ -477,7 +479,7 @@ 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().equals(playQueue.getItem())
|
||||
&& newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) {
|
||||
// Player can have state = IDLE when playback is stopped or failed
|
||||
// and we should retry in this case
|
||||
@@ -564,36 +566,42 @@ public final class Player implements PlaybackListener, Listener {
|
||||
}
|
||||
|
||||
private void initUIsForCurrentPlayerType() {
|
||||
if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN)
|
||||
|| (UIs.get(BackgroundPlayerUi.class).isPresent() && playerType == PlayerType.AUDIO)
|
||||
|| (UIs.get(PopupPlayerUi.class).isPresent() && playerType == PlayerType.POPUP)) {
|
||||
final var playerClass = switch (playerType) {
|
||||
case MAIN -> MainPlayerUi.class;
|
||||
case POPUP -> PopupPlayerUi.class;
|
||||
case BACKGROUND -> BackgroundPlayerUi.class;
|
||||
default -> null; // Should not be possible
|
||||
};
|
||||
if (playerClass == null || UIs.get(playerClass) != null) {
|
||||
// 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.BACKGROUND) {
|
||||
binding = null;
|
||||
} else {
|
||||
binding = PlayerBinding.inflate(LayoutInflater.from(context));
|
||||
}
|
||||
|
||||
switch (playerType) {
|
||||
case MAIN:
|
||||
UIs.destroyAll(PopupPlayerUi.class);
|
||||
UIs.destroyAll(BackgroundPlayerUi.class);
|
||||
UIs.destroyAllOfType(PopupPlayerUi.class);
|
||||
UIs.destroyAllOfType(BackgroundPlayerUi.class);
|
||||
UIs.addAndPrepare(new MainPlayerUi(this, binding));
|
||||
break;
|
||||
case POPUP:
|
||||
UIs.destroyAll(MainPlayerUi.class);
|
||||
UIs.destroyAll(BackgroundPlayerUi.class);
|
||||
UIs.destroyAllOfType(MainPlayerUi.class);
|
||||
UIs.destroyAllOfType(BackgroundPlayerUi.class);
|
||||
UIs.addAndPrepare(new PopupPlayerUi(this, binding));
|
||||
break;
|
||||
case AUDIO:
|
||||
UIs.destroyAll(VideoPlayerUi.class); // destroys both MainPlayerUi and PopupPlayerUi
|
||||
case BACKGROUND:
|
||||
// destroys both MainPlayerUi and PopupPlayerUi
|
||||
UIs.destroyAllOfType(VideoPlayerUi.class);
|
||||
UIs.addAndPrepare(new BackgroundPlayerUi(this));
|
||||
break;
|
||||
}
|
||||
@@ -682,9 +690,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();
|
||||
@@ -698,7 +712,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
progressUpdateDisposable.set(null);
|
||||
streamItemDisposable.clear();
|
||||
|
||||
UIs.destroyAll(Object.class); // destroy every UI: obviously every UI extends Object
|
||||
UIs.destroyAllOfType(null);
|
||||
}
|
||||
|
||||
public void setRecovery() {
|
||||
@@ -923,7 +937,6 @@ public final class Player implements PlaybackListener, Listener {
|
||||
.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
|
||||
@@ -2111,6 +2124,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;
|
||||
@@ -2125,6 +2142,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;
|
||||
@@ -2389,7 +2410,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
}
|
||||
|
||||
public boolean audioPlayerSelected() {
|
||||
return playerType == PlayerType.AUDIO;
|
||||
return playerType == PlayerType.BACKGROUND;
|
||||
}
|
||||
|
||||
public boolean videoPlayerSelected() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 java.lang.ref.WeakReference
|
||||
import java.util.function.Consumer
|
||||
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
|
||||
|
||||
/**
|
||||
* 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"
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
package org.schabi.newpipe.player
|
||||
|
||||
enum class PlayerType {
|
||||
BACKGROUND,
|
||||
MAIN,
|
||||
AUDIO,
|
||||
POPUP
|
||||
POPUP,
|
||||
NONE
|
||||
}
|
||||
|
||||
@@ -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.getInstance();
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* Returns the current [PlayerType] of the [PlayerService] service,
|
||||
* otherwise `null` if no service is running.
|
||||
*
|
||||
* @return Current PlayerType
|
||||
*/
|
||||
val type: PlayerType?
|
||||
get() = this.player?.playerType
|
||||
|
||||
val isPlaying: Boolean
|
||||
get() = this.player?.isPlaying == true
|
||||
|
||||
val isPlayerOpen: Boolean
|
||||
get() = this.player != null
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
val isPlayQueueReady: Boolean
|
||||
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
|
||||
@@ -17,7 +18,6 @@ import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import java.util.function.Consumer
|
||||
import org.schabi.newpipe.MainActivity.DEBUG
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.R
|
||||
@@ -47,8 +47,7 @@ import org.schabi.newpipe.util.image.ImageStrategy
|
||||
*/
|
||||
class MediaBrowserImpl(
|
||||
private val context: Context,
|
||||
// parentId
|
||||
notifyChildrenChanged: Consumer<String>
|
||||
notifyChildrenChanged: (parentId: String) -> Unit
|
||||
) {
|
||||
private val packageValidator = PackageValidator(context)
|
||||
private val database = NewPipeDatabase.getInstance(context)
|
||||
@@ -56,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
|
||||
@@ -198,17 +195,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
|
||||
@@ -217,8 +213,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)
|
||||
@@ -227,10 +224,6 @@ class MediaBrowserImpl(
|
||||
else -> return null
|
||||
}
|
||||
|
||||
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
|
||||
builder.setIconUri(imageUriOrNullIfDisabled(it))
|
||||
}
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
@@ -271,7 +264,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))
|
||||
@@ -291,10 +284,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(),
|
||||
@@ -317,10 +307,10 @@ class MediaBrowserImpl(
|
||||
}
|
||||
|
||||
private fun populateHistory(): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
val history = database.streamHistoryDAO().history.firstOrError()
|
||||
return history.map { items ->
|
||||
items.map { this.createHistoryMediaItem(it) }
|
||||
}
|
||||
return database.streamHistoryDAO().getHistory().firstOrError()
|
||||
.map { items ->
|
||||
items.map { this.createHistoryMediaItem(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun createHistoryMediaItem(streamHistoryEntry: StreamHistoryEntry): MediaBrowserCompat.MediaItem {
|
||||
|
||||
+3
-3
@@ -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
|
||||
@@ -31,6 +30,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
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -216,7 +216,7 @@ class MediaBrowserPlaybackPreparer(
|
||||
}
|
||||
|
||||
val streamId = path[0].toLong()
|
||||
return database.streamHistoryDAO().history
|
||||
return database.streamHistoryDAO().getHistory()
|
||||
.firstOrError()
|
||||
.map { items ->
|
||||
val infoItems = items
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -106,10 +106,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,575 +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.PlayQueueEvent.AppendEvent;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ErrorEvent;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.InitEvent;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.MoveEvent;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RecoveryEvent;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ReorderEvent;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.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.PublishSubject;
|
||||
|
||||
/**
|
||||
* 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 PublishSubject<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 = PublishSubject.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
package org.schabi.newpipe.player.playqueue
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.BackpressureStrategy
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import java.io.Serializable
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.AppendEvent
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ErrorEvent
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.InitEvent
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.MoveEvent
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RecoveryEvent
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ReorderEvent
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.SelectEvent
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem
|
||||
|
||||
/**
|
||||
* PlayQueue is responsible for keeping track of a list of streams and the index of
|
||||
* the stream that should be currently playing.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* This class can be serialized for passing intents, but in order to start the
|
||||
* message bus, it must be initialized.
|
||||
*/
|
||||
abstract class PlayQueue internal constructor(
|
||||
index: Int,
|
||||
startWith: List<PlayQueueItem>
|
||||
) : Serializable {
|
||||
private val queueIndex = AtomicInteger(index)
|
||||
private val history = mutableListOf<PlayQueueItem>()
|
||||
private var backup = mutableListOf<PlayQueueItem>()
|
||||
private var streams = startWith.toMutableList()
|
||||
|
||||
@Transient
|
||||
private var eventBroadcast: PublishSubject<PlayQueueEvent>? = null
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@Transient
|
||||
var broadcastReceiver: Flowable<PlayQueueEvent>? = null
|
||||
private set
|
||||
|
||||
@Transient
|
||||
var isDisposed: Boolean = false
|
||||
private set
|
||||
|
||||
init {
|
||||
if (streams.size > index) {
|
||||
history.add(streams[index])
|
||||
}
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Playlist actions
|
||||
////////////////////////////////////////////////////////////////////////// */
|
||||
|
||||
/**
|
||||
* Initializes the play queue message buses.
|
||||
*
|
||||
* Also starts a self reporter for logging if debug mode is enabled.
|
||||
*/
|
||||
fun init() {
|
||||
eventBroadcast = PublishSubject.create()
|
||||
|
||||
broadcastReceiver =
|
||||
eventBroadcast!!
|
||||
.toFlowable(BackpressureStrategy.BUFFER)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.startWithItem(InitEvent())
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose the play queue by stopping all message buses.
|
||||
*/
|
||||
open fun dispose() {
|
||||
eventBroadcast?.onComplete()
|
||||
eventBroadcast = null
|
||||
broadcastReceiver = null
|
||||
this.isDisposed = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the queue is complete.
|
||||
*
|
||||
* A queue is complete if it has loaded all items in an external playlist
|
||||
* single stream or local queues are always complete.
|
||||
*
|
||||
* @return whether the queue is complete
|
||||
*/
|
||||
abstract val isComplete: Boolean
|
||||
|
||||
/**
|
||||
* Load partial queue in the background, does nothing if the queue is complete.
|
||||
*/
|
||||
abstract fun fetch()
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Readonly ops
|
||||
////////////////////////////////////////////////////////////////////////// */
|
||||
|
||||
/**
|
||||
* Changes the current playing index to a new index.
|
||||
*
|
||||
* This method is guarded using in a circular manner for index exceeding the play queue size.
|
||||
*
|
||||
* Will emit a [SelectEvent] if the index is not the current playing index.
|
||||
*
|
||||
* @param index the index to be set
|
||||
* @return the current index that should be played
|
||||
*/
|
||||
@set:Synchronized
|
||||
var index: Int = 0
|
||||
get() = queueIndex.get()
|
||||
set(index) {
|
||||
val oldIndex = field
|
||||
|
||||
val newIndex: Int
|
||||
|
||||
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 (this.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[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(SelectEvent(oldIndex, newIndex))
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the current item that should be played, or null if the queue is empty
|
||||
*/
|
||||
val item get() = getItem(this.index)
|
||||
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
fun getItem(index: Int) = streams.getOrNull(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
|
||||
*/
|
||||
fun indexOf(item: PlayQueueItem): Int = streams.indexOf(item)
|
||||
|
||||
/**
|
||||
* @return the current size of play queue.
|
||||
*/
|
||||
fun size(): Int = streams.size
|
||||
|
||||
/**
|
||||
* Checks if the play queue is empty.
|
||||
*
|
||||
* @return whether the play queue is empty
|
||||
*/
|
||||
val isEmpty: Boolean
|
||||
get() = streams.isEmpty()
|
||||
|
||||
/**
|
||||
* Determines if the current play queue is shuffled.
|
||||
*
|
||||
* @return whether the play queue is shuffled
|
||||
*/
|
||||
val isShuffled: Boolean
|
||||
get() = backup.isNotEmpty()
|
||||
|
||||
/**
|
||||
* @return an immutable view of the play queue
|
||||
*/
|
||||
fun getStreams(): List<PlayQueueItem> = Collections.unmodifiableList(streams)
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Write ops
|
||||
////////////////////////////////////////////////////////////////////////// */
|
||||
|
||||
/**
|
||||
* Changes the current playing index by an offset amount.
|
||||
*
|
||||
* Will emit a [SelectEvent] if offset is non-zero.
|
||||
*
|
||||
* @param offset the offset relative to the current index
|
||||
*/
|
||||
@Synchronized
|
||||
fun offsetIndex(offset: Int) {
|
||||
this.index += offset
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies that a change has occurred.
|
||||
*/
|
||||
@Synchronized
|
||||
fun notifyChange() {
|
||||
broadcast(AppendEvent(0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the given [PlayQueueItem]s to the current play queue.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Will emit a [AppendEvent] on any given context.
|
||||
*
|
||||
* @param items [PlayQueueItem]s to append
|
||||
*/
|
||||
@Synchronized
|
||||
fun append(items: List<PlayQueueItem>) {
|
||||
val itemList = items.toMutableList()
|
||||
|
||||
if (this.isShuffled) {
|
||||
backup.addAll(itemList)
|
||||
itemList.shuffle()
|
||||
}
|
||||
if (!streams.isEmpty() && streams.last().isAutoQueued && !itemList[0].isAutoQueued) {
|
||||
streams.removeAt(streams.lastIndex)
|
||||
}
|
||||
streams.addAll(itemList)
|
||||
|
||||
broadcast(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.
|
||||
*/
|
||||
fun enqueueNext(item: PlayQueueItem, skipIfSame: Boolean) {
|
||||
val currentIndex = index
|
||||
// if the next item is the same item as the one we want to enqueue, skip if flag is true
|
||||
if (skipIfSame && item == getItem(currentIndex + 1)) {
|
||||
return
|
||||
}
|
||||
append(listOf(item))
|
||||
move(size() - 1, currentIndex + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the item at the given index from the play queue.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Will emit a [RemoveEvent] if the index is within the play queue index range.
|
||||
*
|
||||
* @param index the index of the item to remove
|
||||
*/
|
||||
@Synchronized
|
||||
fun remove(index: Int) {
|
||||
if (index >= streams.size || index < 0) {
|
||||
return
|
||||
}
|
||||
removeInternal(index)
|
||||
broadcast(RemoveEvent(index, this.index))
|
||||
}
|
||||
|
||||
/**
|
||||
* Report an exception for the item at the current index in order and skip to the next one
|
||||
*
|
||||
* This is done as a separate event as the underlying manager may have
|
||||
* different implementation regarding exceptions.
|
||||
*/
|
||||
@Synchronized
|
||||
fun error() {
|
||||
val oldIndex = this.index
|
||||
queueIndex.incrementAndGet()
|
||||
if (streams.size > queueIndex.get()) {
|
||||
history.add(streams[queueIndex.get()])
|
||||
}
|
||||
broadcast(ErrorEvent(oldIndex, this.index))
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun removeInternal(removeIndex: Int) {
|
||||
val currentIndex = queueIndex.get()
|
||||
val 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)
|
||||
}
|
||||
|
||||
backup.remove(getItem(removeIndex)!!)
|
||||
|
||||
history.remove(streams.removeAt(removeIndex))
|
||||
if (streams.size > queueIndex.get()) {
|
||||
history.add(streams[queueIndex.get()])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a queue item at the source index to the target index.
|
||||
*
|
||||
* 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 **AFTER** the
|
||||
* current playing index, then the current playing index is decremented.
|
||||
* Vice versa if the an item after the currently playing is moved **BEFORE**.
|
||||
*
|
||||
* @param source the original index of the item
|
||||
* @param target the new index of the item
|
||||
*/
|
||||
@Synchronized
|
||||
fun move(
|
||||
source: Int,
|
||||
target: Int
|
||||
) {
|
||||
if (source < 0 || target < 0) {
|
||||
return
|
||||
}
|
||||
if (source >= streams.size || target >= streams.size) {
|
||||
return
|
||||
}
|
||||
|
||||
val current = this.index
|
||||
if (source == current) {
|
||||
queueIndex.set(target)
|
||||
} else if (source < current && target >= current) {
|
||||
queueIndex.decrementAndGet()
|
||||
} else if (source > current && target <= current) {
|
||||
queueIndex.incrementAndGet()
|
||||
}
|
||||
|
||||
val playQueueItem = streams.removeAt(source)
|
||||
playQueueItem.isAutoQueued = false
|
||||
streams.add(target, playQueueItem)
|
||||
broadcast(MoveEvent(source, target))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the recovery record of the item at the index.
|
||||
*
|
||||
* Broadcasts a recovery event.
|
||||
*
|
||||
* @param index index of the item
|
||||
* @param position the recovery position
|
||||
*/
|
||||
@Synchronized
|
||||
fun setRecovery(
|
||||
index: Int,
|
||||
position: Long
|
||||
) {
|
||||
streams.getOrNull(index)?.let {
|
||||
it.recoveryPosition = position
|
||||
broadcast(RecoveryEvent(index, position))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the recovery record of the item at the index.
|
||||
*
|
||||
* Broadcasts a recovery event.
|
||||
*
|
||||
* @param index index of the item
|
||||
*/
|
||||
@Synchronized
|
||||
fun unsetRecovery(index: Int) {
|
||||
setRecovery(index, PlayQueueItem.RECOVERY_UNSET)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffles the current play queue
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Will emit a [ReorderEvent] if shuffled.
|
||||
*
|
||||
* @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)
|
||||
*/
|
||||
@Synchronized
|
||||
fun 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.isEmpty()) {
|
||||
backup = streams.toMutableList()
|
||||
}
|
||||
// Can't shuffle a list that's empty or only has one element
|
||||
if (size() <= 2) {
|
||||
return
|
||||
}
|
||||
|
||||
val originalIndex = this.index
|
||||
val currentItem = this.item
|
||||
|
||||
streams.shuffle()
|
||||
|
||||
// Move currentItem to the head of the queue
|
||||
streams.remove(currentItem!!)
|
||||
streams.add(0, currentItem)
|
||||
queueIndex.set(0)
|
||||
|
||||
history.add(currentItem)
|
||||
|
||||
broadcast(ReorderEvent(originalIndex, 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Unshuffles the current play queue if a backup play queue exists.
|
||||
*
|
||||
* This method undoes shuffling and index will be set to the previously playing item if found,
|
||||
* otherwise, the index will reset to 0.
|
||||
*
|
||||
* Will emit a [ReorderEvent] if a backup exists.
|
||||
*/
|
||||
@Synchronized
|
||||
fun unshuffle() {
|
||||
if (backup.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val originIndex = this.index
|
||||
val current = this.item
|
||||
|
||||
streams = backup
|
||||
backup = mutableListOf()
|
||||
|
||||
val newIndex = streams.indexOf(current!!)
|
||||
if (newIndex != -1) {
|
||||
queueIndex.set(newIndex)
|
||||
} else {
|
||||
queueIndex.set(0)
|
||||
}
|
||||
if (streams.size > queueIndex.get()) {
|
||||
history.add(streams[queueIndex.get()])
|
||||
}
|
||||
|
||||
broadcast(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
|
||||
*/
|
||||
@Synchronized
|
||||
fun previous(): Boolean {
|
||||
if (history.size <= 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
history.removeAt(history.size - 1)
|
||||
|
||||
val last = history.removeAt(history.size - 1)
|
||||
this.index = 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
|
||||
*/
|
||||
override fun equals(o: Any?): Boolean = o is PlayQueue && streams == o.streams
|
||||
|
||||
override fun hashCode(): Int = streams.hashCode()
|
||||
|
||||
fun equalStreamsAndIndex(other: PlayQueue?): Boolean {
|
||||
return equals(other) && other!!.index == this.index // NOSONAR: other is not null
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Rx Broadcast
|
||||
////////////////////////////////////////////////////////////////////////// */
|
||||
private fun broadcast(event: PlayQueueEvent) {
|
||||
eventBroadcast?.onNext(event)
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package org.schabi.newpipe.player.playqueue;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.schabi.newpipe.extractor.Image;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public class PlayQueueItem implements Serializable {
|
||||
public static final long RECOVERY_UNSET = Long.MIN_VALUE;
|
||||
private static final String EMPTY_STRING = "";
|
||||
|
||||
@NonNull
|
||||
private final String title;
|
||||
@NonNull
|
||||
private final String url;
|
||||
private final int serviceId;
|
||||
private final long duration;
|
||||
@NonNull
|
||||
private final List<Image> thumbnails;
|
||||
@NonNull
|
||||
private final String uploader;
|
||||
private final String uploaderUrl;
|
||||
@NonNull
|
||||
private final StreamType streamType;
|
||||
|
||||
private boolean isAutoQueued;
|
||||
|
||||
private long recoveryPosition;
|
||||
private Throwable error;
|
||||
|
||||
public PlayQueueItem(@NonNull final StreamInfo info) {
|
||||
this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(),
|
||||
info.getThumbnails(), info.getUploaderName(),
|
||||
info.getUploaderUrl(), info.getStreamType());
|
||||
|
||||
if (info.getStartPosition() > 0) {
|
||||
setRecoveryPosition(info.getStartPosition() * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
PlayQueueItem(@NonNull final StreamInfoItem item) {
|
||||
this(item.getName(), item.getUrl(), item.getServiceId(), item.getDuration(),
|
||||
item.getThumbnails(), item.getUploaderName(),
|
||||
item.getUploaderUrl(), item.getStreamType());
|
||||
}
|
||||
|
||||
@SuppressWarnings("ParameterNumber")
|
||||
private PlayQueueItem(@Nullable final String name, @Nullable final String url,
|
||||
final int serviceId, final long duration,
|
||||
final List<Image> thumbnails, @Nullable final String uploader,
|
||||
final String uploaderUrl, @NonNull final StreamType streamType) {
|
||||
this.title = name != null ? name : EMPTY_STRING;
|
||||
this.url = url != null ? url : EMPTY_STRING;
|
||||
this.serviceId = serviceId;
|
||||
this.duration = duration;
|
||||
this.thumbnails = thumbnails;
|
||||
this.uploader = uploader != null ? uploader : EMPTY_STRING;
|
||||
this.uploaderUrl = uploaderUrl;
|
||||
this.streamType = streamType;
|
||||
|
||||
this.recoveryPosition = RECOVERY_UNSET;
|
||||
}
|
||||
|
||||
/** Whether these two items should be treated as the same stream
|
||||
* for the sake of keeping the same player running when e.g. jumping between timestamps.
|
||||
*
|
||||
* @param other the {@link PlayQueueItem} to compare against.
|
||||
* @return whether the two items are the same so the stream can be re-used.
|
||||
*/
|
||||
public boolean isSameItem(@Nullable final PlayQueueItem other) {
|
||||
if (other == null) {
|
||||
return false;
|
||||
}
|
||||
// We assume that the same service & URL uniquely determines
|
||||
// that we can keep the same stream running.
|
||||
return serviceId == other.serviceId
|
||||
&& url.equals(other.url);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public int getServiceId() {
|
||||
return serviceId;
|
||||
}
|
||||
|
||||
public long getDuration() {
|
||||
return duration;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public List<Image> getThumbnails() {
|
||||
return thumbnails;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getUploader() {
|
||||
return uploader;
|
||||
}
|
||||
|
||||
public String getUploaderUrl() {
|
||||
return uploaderUrl;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public StreamType getStreamType() {
|
||||
return streamType;
|
||||
}
|
||||
|
||||
public long getRecoveryPosition() {
|
||||
return recoveryPosition;
|
||||
}
|
||||
|
||||
/*package-private*/ void setRecoveryPosition(final long recoveryPosition) {
|
||||
this.recoveryPosition = recoveryPosition;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Throwable getError() {
|
||||
return error;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public Single<StreamInfo> getStream() {
|
||||
return ExtractorHelper.getStreamInfo(this.serviceId, this.url, false)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.doOnError(throwable -> error = throwable);
|
||||
}
|
||||
|
||||
public boolean isAutoQueued() {
|
||||
return isAutoQueued;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Item States, keep external access out
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
public void setAutoQueued(final boolean autoQueued) {
|
||||
isAutoQueued = autoQueued;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.schabi.newpipe.player.playqueue
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import java.io.Serializable
|
||||
import java.util.Objects
|
||||
import org.schabi.newpipe.extractor.Image
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
|
||||
class PlayQueueItem private constructor(
|
||||
val title: String,
|
||||
val url: String,
|
||||
val serviceId: Int,
|
||||
val duration: Long,
|
||||
val thumbnails: List<Image>,
|
||||
val uploader: String,
|
||||
val uploaderUrl: String?,
|
||||
val streamType: StreamType
|
||||
) : Serializable {
|
||||
//
|
||||
// ////////////////////////////////////////////////////////////////////// */
|
||||
// Item States, keep external access out
|
||||
//
|
||||
// ////////////////////////////////////////////////////////////////////// */
|
||||
var isAutoQueued: Boolean = false
|
||||
|
||||
// package-private
|
||||
var recoveryPosition = RECOVERY_UNSET
|
||||
var error: Throwable? = null
|
||||
private set
|
||||
|
||||
constructor(info: StreamInfo) : this(
|
||||
info.name.orEmpty(),
|
||||
info.url.orEmpty(),
|
||||
info.serviceId,
|
||||
info.duration,
|
||||
info.thumbnails,
|
||||
info.uploaderName.orEmpty(),
|
||||
info.uploaderUrl,
|
||||
info.streamType
|
||||
) {
|
||||
if (info.startPosition > 0) {
|
||||
this.recoveryPosition = info.startPosition * 1000
|
||||
}
|
||||
}
|
||||
|
||||
constructor(item: StreamInfoItem) : this(
|
||||
item.name.orEmpty(),
|
||||
item.url.orEmpty(),
|
||||
item.serviceId,
|
||||
item.duration,
|
||||
item.thumbnails,
|
||||
item.uploaderName.orEmpty(),
|
||||
item.uploaderUrl,
|
||||
item.streamType
|
||||
)
|
||||
|
||||
val stream: Single<StreamInfo>
|
||||
get() =
|
||||
ExtractorHelper
|
||||
.getStreamInfo(serviceId, url, false)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.doOnError { throwable -> error = throwable }
|
||||
|
||||
override fun equals(o: Any?) = o is PlayQueueItem && serviceId == o.serviceId && url == o.url
|
||||
|
||||
override fun hashCode() = Objects.hash(url, serviceId)
|
||||
|
||||
companion object {
|
||||
const val RECOVERY_UNSET = Long.MIN_VALUE
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user