mirror of
https://github.com/TeamNewPipe/NewPipe
synced 2026-01-14 10:42:40 +00:00
Compare commits
479 Commits
fix/peertu
...
v0.27.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9828586762 | ||
|
|
8caaa6d297 | ||
|
|
83ca6b9468 | ||
|
|
24e65ef018 | ||
|
|
a69bbab732 | ||
|
|
a557ac3c7b | ||
|
|
d61b4b89ea | ||
|
|
b8daf16b92 | ||
|
|
caa3812e13 | ||
|
|
23a087c498 | ||
|
|
c3c39a7b24 | ||
|
|
00770fc634 | ||
|
|
5bf77160f7 | ||
|
|
d9da84c412 | ||
|
|
b3a6318672 | ||
|
|
67b41b970d | ||
|
|
3738e30949 | ||
|
|
0ba73b11c1 | ||
|
|
13baaa31cd | ||
|
|
f0db2aa43c | ||
|
|
f704721b59 | ||
|
|
7abf0f4886 | ||
|
|
c915b6e68b | ||
|
|
0b28c688c6 | ||
|
|
2756ef6d2f | ||
|
|
7da1d30010 | ||
|
|
8e192acb63 | ||
|
|
d8423499dc | ||
|
|
974167fcb8 | ||
|
|
6afdbd6fd3 | ||
|
|
d8668ed226 | ||
|
|
d75a6eaa41 | ||
|
|
235fb92638 | ||
|
|
ea18b4ea1f | ||
|
|
58f5ec0181 | ||
|
|
e42c9abdde | ||
|
|
5e7ad6ffd1 | ||
|
|
4c8238874e | ||
|
|
38d4887901 | ||
|
|
c9051d33c1 | ||
|
|
3cc0205def | ||
|
|
90979e2a81 | ||
|
|
e66e1b542c | ||
|
|
92e9c3e42e | ||
|
|
4591c09637 | ||
|
|
e1ce3fef1b | ||
|
|
3c0a200f7b | ||
|
|
bef5907ec3 | ||
|
|
f0beb662aa | ||
|
|
92402685f8 | ||
|
|
3703fed1a5 | ||
|
|
a3bbbf03b4 | ||
|
|
1d3a69a29f | ||
|
|
10c57b15da | ||
|
|
b85f7a6747 | ||
|
|
3f94e7b638 | ||
|
|
2af95cc1d4 | ||
|
|
cefdefdfd2 | ||
|
|
37f7fa7ef4 | ||
|
|
e687eb5631 | ||
|
|
88c3af7647 | ||
|
|
ddd6c8cbf1 | ||
|
|
81220f90d6 | ||
|
|
e0268a91ad | ||
|
|
29e4135aaa | ||
|
|
5d9adce40d | ||
|
|
d3afde8789 | ||
|
|
d8a5d5545d | ||
|
|
bed3516687 | ||
|
|
3a014d8d46 | ||
|
|
58ae7fbccb | ||
|
|
b06a9618d4 | ||
|
|
434c4a5cbc | ||
|
|
c34d30dc17 | ||
|
|
0d4c1bee3f | ||
|
|
34a25d0be3 | ||
|
|
3134f5e747 | ||
|
|
1732584e5e | ||
|
|
f50cafbac1 | ||
|
|
bc7c3f48ad | ||
|
|
b760419fd5 | ||
|
|
5cf3c58d0e | ||
|
|
206d1b6db4 | ||
|
|
2e318b8b03 | ||
|
|
5bdb6f18d6 | ||
|
|
2e53a99361 | ||
|
|
bec18e13d3 | ||
|
|
7edd471ec5 | ||
|
|
e6a4a3fa4f | ||
|
|
de2a139340 | ||
|
|
9d6ac67c46 | ||
|
|
6f7b905983 | ||
|
|
bcd4626008 | ||
|
|
27730a20d6 | ||
|
|
4aa0190175 | ||
|
|
6dd62335e9 | ||
|
|
32d2606a65 | ||
|
|
2051334bba | ||
|
|
575e809004 | ||
|
|
66e8e2a696 | ||
|
|
55373c95d9 | ||
|
|
04bdc1cc0b | ||
|
|
1d8850d1b2 | ||
|
|
f98548698a | ||
|
|
4b1824e8c1 | ||
|
|
17e88f1749 | ||
|
|
5edafca05a | ||
|
|
2c4c283099 | ||
|
|
9fb8125655 | ||
|
|
aab6580195 | ||
|
|
30f0db1d28 | ||
|
|
5a4dae2070 | ||
|
|
8345f348f6 | ||
|
|
9220e32463 | ||
|
|
845e72bf4a | ||
|
|
49429ff40a | ||
|
|
3df21ad25e | ||
|
|
d0f4600be4 | ||
|
|
0fa2e76c3e | ||
|
|
9ff1b5230f | ||
|
|
65eb631711 | ||
|
|
6c99557553 | ||
|
|
2b4357fa87 | ||
|
|
cda4b3faaa | ||
|
|
5d09a88335 | ||
|
|
edd4f6b9f3 | ||
|
|
1e7e2109d2 | ||
|
|
b31d3831e6 | ||
|
|
0f81a0504c | ||
|
|
4a7fda95ae | ||
|
|
ee3455e1e5 | ||
|
|
f9fc1cd817 | ||
|
|
76f1e588f7 | ||
|
|
f3b458c803 | ||
|
|
00566ed4d4 | ||
|
|
e4a07411b8 | ||
|
|
2c1bb2706f | ||
|
|
aa84d6fc8f | ||
|
|
d76e9b0bd8 | ||
|
|
b4016c91c1 | ||
|
|
5f32d001cc | ||
|
|
8c9287d0c8 | ||
|
|
3f37e27852 | ||
|
|
f41ab8b086 | ||
|
|
ad68f784ae | ||
|
|
4b6392df54 | ||
|
|
94ea329b50 | ||
|
|
591ed2e01f | ||
|
|
78cf9aaa7d | ||
|
|
f9494a294f | ||
|
|
0dd4553700 | ||
|
|
4f7b36cd70 | ||
|
|
5d350aec87 | ||
|
|
059db6fb31 | ||
|
|
4c709b2c4d | ||
|
|
8f4cd032b7 | ||
|
|
67629938d6 | ||
|
|
9aff49bd88 | ||
|
|
5b999a88f8 | ||
|
|
482531836f | ||
|
|
b3c82f54df | ||
|
|
77fa4bbe2f | ||
|
|
495c9850b4 | ||
|
|
c0f8d145f8 | ||
|
|
80f33daeeb | ||
|
|
a16dcb63b5 | ||
|
|
b871b5d2dd | ||
|
|
e876647af5 | ||
|
|
8d59812827 | ||
|
|
e39ac885de | ||
|
|
e6965622bd | ||
|
|
0d8d3479e1 | ||
|
|
35c1dfd145 | ||
|
|
096115def7 | ||
|
|
e784af3e2d | ||
|
|
ce30108efc | ||
|
|
edbd623e21 | ||
|
|
7cfd537755 | ||
|
|
ddd6d03e0b | ||
|
|
b4a0e08d9d | ||
|
|
545f9ae5f3 | ||
|
|
be4a5a5f3e | ||
|
|
3dc593fe88 | ||
|
|
e8ed18f1cf | ||
|
|
bf8890b0df | ||
|
|
e5fda35c51 | ||
|
|
84d50da009 | ||
|
|
2cf7764714 | ||
|
|
9fab0ec94f | ||
|
|
6d694518fe | ||
|
|
5265b767cb | ||
|
|
d10a93fe4f | ||
|
|
995986ecc7 | ||
|
|
6d0bb02544 | ||
|
|
6f51c47dc9 | ||
|
|
626daf89c1 | ||
|
|
b18ccffeb4 | ||
|
|
2ab2185e0a | ||
|
|
be47609405 | ||
|
|
5dee7a5262 | ||
|
|
bff7ada2d1 | ||
|
|
ed33d1d4f7 | ||
|
|
64e64f72f7 | ||
|
|
d3c783832a | ||
|
|
d963b69d5c | ||
|
|
49ce9ba387 | ||
|
|
d63a6d3f75 | ||
|
|
3d5a8af52b | ||
|
|
1cf670dad9 | ||
|
|
b50e3c07d2 | ||
|
|
fe7d1692c3 | ||
|
|
0758cd6980 | ||
|
|
e80b6b3057 | ||
|
|
9c86afe40d | ||
|
|
db4619f5a4 | ||
|
|
f90d74ca31 | ||
|
|
609f0a2eee | ||
|
|
77bbbc88f8 | ||
|
|
cdb79ef78a | ||
|
|
1630e309fb | ||
|
|
2d4f56f57c | ||
|
|
d622993483 | ||
|
|
c68a6ee0ed | ||
|
|
94c1438913 | ||
|
|
e206a26a85 | ||
|
|
242e20316b | ||
|
|
279fd2399d | ||
|
|
a5fcb41ab0 | ||
|
|
cb4f656673 | ||
|
|
b9e5ee6759 | ||
|
|
1084b7c3ad | ||
|
|
39c06c5461 | ||
|
|
b9c7f8769b | ||
|
|
dc45adf7f2 | ||
|
|
a69af42f7f | ||
|
|
1a5dfae7a0 | ||
|
|
d41b5d80ad | ||
|
|
f0bcb3ba28 | ||
|
|
7da35bf71d | ||
|
|
03c339dd4b | ||
|
|
11c74bd26b | ||
|
|
0a292cf893 | ||
|
|
ac6811867f | ||
|
|
0c9df501e8 | ||
|
|
4c4f9b45d9 | ||
|
|
5a921c9f10 | ||
|
|
bdc2aa2b39 | ||
|
|
b508dd69be | ||
|
|
f8b756c8bc | ||
|
|
027b829c38 | ||
|
|
0a2d6d1d62 | ||
|
|
1b485ddb5a | ||
|
|
0085ca6416 | ||
|
|
87dca0f7ec | ||
|
|
37af2c87e8 | ||
|
|
bf908f0b7d | ||
|
|
8d463b9577 | ||
|
|
4f7d206736 | ||
|
|
35073c780d | ||
|
|
0a8f28b1c6 | ||
|
|
af2375948d | ||
|
|
df2e0be08d | ||
|
|
ff1aca272e | ||
|
|
f2e352832a | ||
|
|
ad0855ac83 | ||
|
|
d7ef9b1f0c | ||
|
|
40a3e1b18a | ||
|
|
25a73090f5 | ||
|
|
a239a26b17 | ||
|
|
06d256294f | ||
|
|
81ad50e82a | ||
|
|
23de9bf93e | ||
|
|
5c46412faa | ||
|
|
076e9eee01 | ||
|
|
2103a04092 | ||
|
|
58517d1d27 | ||
|
|
aa1847189b | ||
|
|
5d101e7b88 | ||
|
|
e2de83188a | ||
|
|
2a1b506d98 | ||
|
|
b798ff5c92 | ||
|
|
673aa0a87b | ||
|
|
779ea19222 | ||
|
|
a1f2b7f8e8 | ||
|
|
fcb855cea9 | ||
|
|
50fb48f66d | ||
|
|
0acc3532c9 | ||
|
|
8bf2d996ea | ||
|
|
748c2babe9 | ||
|
|
6859f73c54 | ||
|
|
b1faed586d | ||
|
|
6c848b4766 | ||
|
|
725c18eada | ||
|
|
992bb5d7be | ||
|
|
9e353f1cdc | ||
|
|
8f83e39970 | ||
|
|
0eae9e7cdc | ||
|
|
031b893196 | ||
|
|
64da7a06c0 | ||
|
|
57eaa1bbe1 | ||
|
|
109d06b4bb | ||
|
|
0d9910cbbe | ||
|
|
8fbc8ffc7c | ||
|
|
f2ee3859ab | ||
|
|
89dc44be61 | ||
|
|
6ab8716e69 | ||
|
|
5c7c382323 | ||
|
|
78b4b9441e | ||
|
|
9e55014a13 | ||
|
|
6f23b56b06 | ||
|
|
1519527356 | ||
|
|
6b3a178f2a | ||
|
|
604419dd1f | ||
|
|
c48e702a50 | ||
|
|
1061bce4f3 | ||
|
|
013d513450 | ||
|
|
dca32efadf | ||
|
|
28d952a643 | ||
|
|
a2a717bd49 | ||
|
|
753a92055c | ||
|
|
371f986773 | ||
|
|
a1e8b9be4e | ||
|
|
c076a0f771 | ||
|
|
dfbd39e898 | ||
|
|
b5893f3fa3 | ||
|
|
e3614cb932 | ||
|
|
193c3e5b3d | ||
|
|
c03c344f49 | ||
|
|
25e3031830 | ||
|
|
b7911a8fd8 | ||
|
|
88384dc35e | ||
|
|
39b4ed082c | ||
|
|
d87aa23ae0 | ||
|
|
be548dcb52 | ||
|
|
4357a34339 | ||
|
|
2c03ba204e | ||
|
|
2c98d079de | ||
|
|
16cd47fa2e | ||
|
|
74a8bfba93 | ||
|
|
c929f00456 | ||
|
|
bb062f07f9 | ||
|
|
c3d1e75a8f | ||
|
|
506e3724a6 | ||
|
|
4859ab67d4 | ||
|
|
6d84d19520 | ||
|
|
8627efd0a1 | ||
|
|
6d13cf5e71 | ||
|
|
7e2ab0d384 | ||
|
|
19640d5e7c | ||
|
|
d1a82a85cd | ||
|
|
b1ab261890 | ||
|
|
038278283a | ||
|
|
c74bd11a6f | ||
|
|
f2c2f1735e | ||
|
|
4e41e12bd2 | ||
|
|
6df808f266 | ||
|
|
2cb973f150 | ||
|
|
b5463cf5e1 | ||
|
|
862546205a | ||
|
|
7c1790bbfd | ||
|
|
2d16a06bc4 | ||
|
|
25cf917969 | ||
|
|
d09c650afd | ||
|
|
2b833c5250 | ||
|
|
510db568eb | ||
|
|
e4003c842b | ||
|
|
68957d3880 | ||
|
|
e6747066ae | ||
|
|
62f0abee47 | ||
|
|
9118ecd68f | ||
|
|
15fd47c7f2 | ||
|
|
ef40ac7bb3 | ||
|
|
881d04ba1e | ||
|
|
4af5b5f6f2 | ||
|
|
90f0809029 | ||
|
|
db5ed48dbb | ||
|
|
ba84e7eead | ||
|
|
e51067177e | ||
|
|
f3859ed710 | ||
|
|
0db12e5561 | ||
|
|
ac5f991c0c | ||
|
|
4a0ff3f7ef | ||
|
|
601b1ef742 | ||
|
|
d957725805 | ||
|
|
4201723d10 | ||
|
|
bef79e77aa | ||
|
|
32f74273f0 | ||
|
|
c69bcaafbb | ||
|
|
50d7d1b7b3 | ||
|
|
c06d61a83c | ||
|
|
bc4f0c699f | ||
|
|
1e8efa7165 | ||
|
|
d4019f4b54 | ||
|
|
3f0f66f106 | ||
|
|
8f644e8aaf | ||
|
|
27f77518fe | ||
|
|
b56f3b3324 | ||
|
|
0195655479 | ||
|
|
3c91ec33ae | ||
|
|
6b3f51e5ea | ||
|
|
d6a1170ddb | ||
|
|
428a7d418b | ||
|
|
40d102fcb5 | ||
|
|
1db73370a7 | ||
|
|
8b63b437d8 | ||
|
|
78e577d260 | ||
|
|
96a7cc2971 | ||
|
|
9eedbae879 | ||
|
|
38d3b3c7ef | ||
|
|
e4d3b74f1b | ||
|
|
54f3003a6f | ||
|
|
cbc7b8ce18 | ||
|
|
ec7d01b794 | ||
|
|
3edd4c012d | ||
|
|
3243f97ff2 | ||
|
|
c658f28b02 | ||
|
|
5ab3a4a9e0 | ||
|
|
cb00c57009 | ||
|
|
cd2884d412 | ||
|
|
471137093a | ||
|
|
57064479c8 | ||
|
|
528bd502b4 | ||
|
|
90bc1905f5 | ||
|
|
a01e59e9db | ||
|
|
3f944c1bb2 | ||
|
|
43ef852117 | ||
|
|
bf22515bcd | ||
|
|
4c17c7b45b | ||
|
|
ec21200787 | ||
|
|
25fea73704 | ||
|
|
2377d85efb | ||
|
|
ddef550637 | ||
|
|
2f0ed7f3b7 | ||
|
|
b6bdd359d6 | ||
|
|
a4453bc699 | ||
|
|
3e87c40856 | ||
|
|
d80e531a2e | ||
|
|
d25e84a461 | ||
|
|
05cc520665 | ||
|
|
eeec6fd002 | ||
|
|
795bc82c7f | ||
|
|
7742c40ac0 | ||
|
|
d9e2ada369 | ||
|
|
5d6158ea76 | ||
|
|
00257e969e | ||
|
|
135f0f7249 | ||
|
|
fdd8b76add | ||
|
|
6b7ffbba4c | ||
|
|
8cfba4003d | ||
|
|
01b46edf1a | ||
|
|
f8599d17c2 | ||
|
|
db7de05f2b | ||
|
|
bb1f5d8f38 | ||
|
|
65680b2ccf | ||
|
|
c8ffe65acf | ||
|
|
26b29ca78d | ||
|
|
38db0cc713 | ||
|
|
ee217eb9b7 | ||
|
|
2b37721a6e | ||
|
|
0821f6463a | ||
|
|
16b0df69b1 | ||
|
|
8ad7bf60d7 | ||
|
|
898a936064 | ||
|
|
4e401bc059 | ||
|
|
9ecef6f011 | ||
|
|
ba394a7ab4 | ||
|
|
d32490a4be | ||
|
|
6526ff1612 | ||
|
|
bb5390d63a | ||
|
|
bd1aae8d66 | ||
|
|
c24aed054f | ||
|
|
0aa08a5e40 | ||
|
|
3c48825699 | ||
|
|
bfb56b4144 | ||
|
|
ba8370bcfd | ||
|
|
813f55152a | ||
|
|
270a541a7c | ||
|
|
c34549a47d | ||
|
|
96d6b309ec |
3
.github/DISCUSSION_TEMPLATE/questions.yml
vendored
3
.github/DISCUSSION_TEMPLATE/questions.yml
vendored
@@ -1,6 +1,3 @@
|
||||
name: Question
|
||||
description: Ask about anything NewPipe-related
|
||||
labels: [question]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: "Checklist"
|
||||
options:
|
||||
- label: "I am able to reproduce the bug with the [latest version](https://github.com/TeamNewPipe/NewPipe/releases/latest)."
|
||||
- label: "I am able to reproduce the bug with the latest version given here: [CLICK THIS LINK](https://github.com/TeamNewPipe/NewPipe/releases/latest)."
|
||||
required: true
|
||||
- label: "I made sure that there are *no existing issues* - [open](https://github.com/TeamNewPipe/NewPipe/issues) or [closed](https://github.com/TeamNewPipe/NewPipe/issues?q=is%3Aissue+is%3Aclosed) - which I could contribute my information to."
|
||||
required: true
|
||||
|
||||
17
.github/changed-lines-count-labeler.yml
vendored
Normal file
17
.github/changed-lines-count-labeler.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# Add 'size/small' label to any changes with less than 50 lines
|
||||
size/small:
|
||||
max: 49
|
||||
|
||||
# Add 'size/medium' label to any changes between 50 and 249 lines
|
||||
size/medium:
|
||||
min: 50
|
||||
max: 249
|
||||
|
||||
# Add 'size/large' label to any changes between 250 and 749 lines
|
||||
size/large:
|
||||
min: 250
|
||||
max: 749
|
||||
|
||||
# Add 'size/giant' label to any changes for more than 749 lines
|
||||
size/giant:
|
||||
min: 750
|
||||
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -36,8 +36,8 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: gradle/wrapper-validation-action@v1
|
||||
- uses: actions/checkout@v4
|
||||
- uses: gradle/wrapper-validation-action@v2
|
||||
|
||||
- name: create and checkout branch
|
||||
# push events already checked out the branch
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
run: git checkout -B "$BRANCH"
|
||||
|
||||
- name: set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: "temurin"
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: app
|
||||
path: app/build/outputs/apk/debug/*.apk
|
||||
@@ -80,10 +80,10 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: "temurin"
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
script: ./gradlew connectedCheck --stacktrace
|
||||
|
||||
- name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: android-test-report-api${{ matrix.api-level }}
|
||||
@@ -111,19 +111,19 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: "temurin"
|
||||
cache: 'gradle'
|
||||
|
||||
- name: Cache SonarCloud packages
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.sonar/cache
|
||||
key: ${{ runner.os }}-sonar
|
||||
|
||||
14
.github/workflows/image-minimizer.js
vendored
14
.github/workflows/image-minimizer.js
vendored
@@ -17,6 +17,8 @@ module.exports = async ({github, context}) => {
|
||||
initialBody = context.payload.comment.body;
|
||||
} else if (context.eventName == 'issues') {
|
||||
initialBody = context.payload.issue.body;
|
||||
} else if (context.eventName == 'pull_request') {
|
||||
initialBody = context.payload.pull_request.body;
|
||||
} else {
|
||||
console.log('Aborting: No body found');
|
||||
return;
|
||||
@@ -74,9 +76,17 @@ module.exports = async ({github, context}) => {
|
||||
repo: context.repo.repo,
|
||||
body: newBody
|
||||
});
|
||||
} else if (context.eventName == 'pull_request') {
|
||||
console.log('Updating pull request', context.payload.pull_request.number);
|
||||
await github.rest.pulls.update({
|
||||
pull_number: context.payload.pull_request.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: newBody
|
||||
});
|
||||
}
|
||||
|
||||
// Asnyc replace function from https://stackoverflow.com/a/48032528
|
||||
// Async replace function from https://stackoverflow.com/a/48032528
|
||||
async function replaceAsync(str, regex, asyncFn) {
|
||||
const promises = [];
|
||||
str.replace(regex, (match, ...args) => {
|
||||
@@ -128,7 +138,7 @@ module.exports = async ({github, context}) => {
|
||||
if (shouldModify) {
|
||||
wasMatchModified = true;
|
||||
console.log(`Modifying match '${match}'`);
|
||||
return `<img alt="${g1}" src="${g2}" width=${Math.min(600, (IMG_MAX_HEIGHT_PX * probeAspectRatio).toFixed(0))} />`;
|
||||
return `<img alt="${g1}" src="${g2}" width=${Math.min(600, Math.floor(IMG_MAX_HEIGHT_PX * probeAspectRatio))} />`;
|
||||
}
|
||||
|
||||
console.log(`Match '${match}' is ok/will not be modified`);
|
||||
|
||||
8
.github/workflows/image-minimizer.yml
vendored
8
.github/workflows/image-minimizer.yml
vendored
@@ -5,6 +5,8 @@ on:
|
||||
types: [created, edited]
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
pull_request:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
@@ -15,9 +17,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
@@ -25,7 +27,7 @@ jobs:
|
||||
run: npm i probe-image-size@7.2.3 --ignore-scripts
|
||||
|
||||
- name: Minimize simple images
|
||||
uses: actions/github-script@v6
|
||||
uses: actions/github-script@v7
|
||||
timeout-minutes: 3
|
||||
with:
|
||||
script: |
|
||||
|
||||
18
.github/workflows/pr-labeler.yml
vendored
Normal file
18
.github/workflows/pr-labeler.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: "PR size labeler"
|
||||
on: [pull_request_target]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
changed-lines-count-labeler:
|
||||
runs-on: ubuntu-latest
|
||||
name: Automatically labelling pull requests based on the changed lines count
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Set a label
|
||||
uses: TeamNewPipe/changed-lines-count-labeler@main
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
configuration-path: .github/changed-lines-count-labeler.yml
|
||||
21
README.md
21
README.md
@@ -13,18 +13,19 @@
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe/actions" alt="Build Status"><img src="https://github.com/TeamNewPipe/NewPipe/workflows/CI/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>
|
||||
<a href="https://web.libera.chat/#newpipe" alt="IRC channel: #newpipe"><img src="https://img.shields.io/badge/IRC%20chat-%23newpipe-brightgreen.svg"></a>
|
||||
<a href="https://www.bountysource.com/teams/newpipe" alt="Bountysource bounties"><img src="https://img.shields.io/bountysource/team/newpipe/activity.svg?colorB=cd201f"></a>
|
||||
<a href="https://matrix.to/#/#newpipe:matrix.newpipe-ev.de" alt="Matrix channel: #newpipe"><img src="https://img.shields.io/badge/Matrix%20chat-%23newpipe-blue"></a>
|
||||
</p>
|
||||
<hr>
|
||||
<p align="center"><a href="#screenshots">Screenshots</a> • <a href="#supported-services">Supported Services</a> • <a href="#description">Description</a> • <a href="#features">Features</a> • <a href="#installation-and-updates">Installation and updates</a> • <a href="#contribution">Contribution</a> • <a href="#donate">Donate</a> • <a href="#license">License</a></p>
|
||||
<p align="center"><a href="https://newpipe.net">Website</a> • <a href="https://newpipe.net/blog/">Blog</a> • <a href="https://newpipe.net/FAQ/">FAQ</a> • <a href="https://newpipe.net/press/">Press</a></p>
|
||||
<hr>
|
||||
|
||||
*Read this in other languages: [English](README.md), [Español](doc/README.es.md), [हिन्दी](doc/README.hi.md), [한국어](doc/README.ko.md), [Soomaali](doc/README.so.md), [Português Brasil](doc/README.pt_BR.md), [Polski](doc/README.pl.md), [日本語](doc/README.ja.md), [Română](doc/README.ro.md), [Türkçe](doc/README.tr.md), [正體中文](doc/README.zh_TW.md).*
|
||||
*Read this document in other languages: [Deutsch](doc/README.de.md), [English](README.md), [Español](doc/README.es.md), [Français](doc/README.fr.md), [हिन्दी](doc/README.hi.md), [Italiano](doc/README.it.md), [한국어](doc/README.ko.md), [Português Brasil](doc/README.pt_BR.md), [Polski](doc/README.pl.md), [ਪੰਜਾਬੀ ](doc/README.pa.md), [日本語](doc/README.ja.md), [Română](doc/README.ro.md), [Soomaali](doc/README.so.md), [Türkçe](doc/README.tr.md), [正體中文](doc/README.zh_TW.md), [অসমীয়া](doc/README.asm.md), [Српски](doc/README.sr.md)*
|
||||
|
||||
<b>WARNING: THIS APP IS IN BETA, SO YOU MAY ENCOUNTER BUGS. IF YOU DO, OPEN AN ISSUE IN OUR GITHUB REPOSITORY BY FILLING OUT THE ISSUE TEMPLATE.</b>
|
||||
|
||||
<b>PUTTING NEWPIPE, OR ANY FORK OF IT, INTO THE GOOGLE PLAY STORE VIOLATES THEIR TERMS AND CONDITIONS.</b>
|
||||
> [!warning]
|
||||
> <b>THIS APP IS IN BETA, SO YOU MAY ENCOUNTER BUGS. IF YOU DO, OPEN AN ISSUE IN OUR GITHUB REPOSITORY BY FILLING OUT THE ISSUE TEMPLATE.</b>
|
||||
>
|
||||
> <b>PUTTING NEWPIPE, OR ANY FORK OF IT, INTO THE GOOGLE PLAY STORE VIOLATES THEIR TERMS AND CONDITIONS.</b>
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -126,16 +127,6 @@ If you like NewPipe, you're welcome to send a donation. We prefer Liberapay, as
|
||||
<td><a href="https://liberapay.com/TeamNewPipe/"><img src="assets/liberapay_qr_code.png" alt="Visit NewPipe at liberapay.com" width="100px"></a></td>
|
||||
<td><a href="https://liberapay.com/TeamNewPipe/donate"><img src="assets/liberapay_donate_button.svg" alt="Donate via Liberapay" height="35px"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="https://bitcoin.org/img/icons/logotop.svg" alt="Bitcoin"></td>
|
||||
<td><img src="assets/bitcoin_qr_code.png" alt="Bitcoin QR code" width="100px"></td>
|
||||
<td><samp>16A9J59ahMRqkLSZjhYj33n9j3fMztFxnh</samp></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="https://www.bountysource.com/teams/newpipe"><img src="https://upload.wikimedia.org/wikipedia/commons/thumb/2/22/Bountysource.png/320px-Bountysource.png" alt="Bountysource" width="190px"></a></td>
|
||||
<td><a href="https://www.bountysource.com/teams/newpipe"><img src="assets/bountysource_qr_code.png" alt="Visit NewPipe at bountysource.com" width="100px"></a></td>
|
||||
<td><a href="https://www.bountysource.com/teams/newpipe/issues"><img src="https://img.shields.io/bountysource/team/newpipe/activity.svg?colorB=cd201f" height="30px" alt="Check out how many bounties you can earn."></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Privacy Policy
|
||||
|
||||
@@ -12,7 +12,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 33
|
||||
compileSdk 34
|
||||
namespace 'org.schabi.newpipe'
|
||||
|
||||
defaultConfig {
|
||||
@@ -20,8 +20,8 @@ android {
|
||||
resValue "string", "app_name", "NewPipe"
|
||||
minSdk 21
|
||||
targetSdk 33
|
||||
versionCode 993
|
||||
versionName "0.25.1"
|
||||
versionCode 997
|
||||
versionName "0.27.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -50,9 +50,6 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the release build type at the end of the list to override 'archivesBaseName' of
|
||||
// debug build. This seems to be a Gradle bug, therefore
|
||||
// TODO: update Gradle version
|
||||
release {
|
||||
if (System.properties.containsKey('packageSuffix')) {
|
||||
applicationIdSuffix System.getProperty('packageSuffix')
|
||||
@@ -101,7 +98,9 @@ android {
|
||||
resources {
|
||||
// remove two files which belong to jsoup
|
||||
// no idea how they ended up in the META-INF dir...
|
||||
excludes += ['META-INF/README.md', 'META-INF/CHANGES']
|
||||
excludes += ['META-INF/README.md', 'META-INF/CHANGES',
|
||||
// 'COPYRIGHT' belongs to RxJava...
|
||||
'META-INF/COPYRIGHT']
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,19 +108,18 @@ android {
|
||||
ext {
|
||||
checkstyleVersion = '10.12.1'
|
||||
|
||||
androidxLifecycleVersion = '2.5.1'
|
||||
androidxRoomVersion = '2.4.3'
|
||||
androidxWorkVersion = '2.7.1'
|
||||
androidxLifecycleVersion = '2.6.2'
|
||||
androidxRoomVersion = '2.6.1'
|
||||
androidxWorkVersion = '2.8.1'
|
||||
|
||||
icepickVersion = '3.2.0'
|
||||
exoPlayerVersion = '2.18.7'
|
||||
googleAutoServiceVersion = '1.0.1'
|
||||
googleAutoServiceVersion = '1.1.1'
|
||||
groupieVersion = '2.10.1'
|
||||
markwonVersion = '4.6.2'
|
||||
|
||||
leakCanaryVersion = '2.9.1'
|
||||
leakCanaryVersion = '2.12'
|
||||
stethoVersion = '1.6.0'
|
||||
mockitoVersion = '4.0.0'
|
||||
}
|
||||
|
||||
configurations {
|
||||
@@ -136,7 +134,7 @@ checkstyle {
|
||||
toolVersion = checkstyleVersion
|
||||
}
|
||||
|
||||
task runCheckstyle(type: Checkstyle) {
|
||||
tasks.register('runCheckstyle', Checkstyle) {
|
||||
source 'src'
|
||||
include '**/*.java'
|
||||
exclude '**/gen/**'
|
||||
@@ -157,7 +155,7 @@ task runCheckstyle(type: Checkstyle) {
|
||||
def outputDir = "${project.buildDir}/reports/ktlint/"
|
||||
def inputFiles = project.fileTree(dir: "src", include: "**/*.kt")
|
||||
|
||||
task runKtlint(type: JavaExec) {
|
||||
tasks.register('runKtlint', JavaExec) {
|
||||
inputs.files(inputFiles)
|
||||
outputs.dir(outputDir)
|
||||
getMainClass().set("com.pinterest.ktlint.Main")
|
||||
@@ -166,7 +164,7 @@ task runKtlint(type: JavaExec) {
|
||||
jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED")
|
||||
}
|
||||
|
||||
task formatKtlint(type: JavaExec) {
|
||||
tasks.register('formatKtlint', JavaExec) {
|
||||
inputs.files(inputFiles)
|
||||
outputs.dir(outputDir)
|
||||
getMainClass().set("com.pinterest.ktlint.Main")
|
||||
@@ -192,7 +190,7 @@ sonar {
|
||||
|
||||
dependencies {
|
||||
/** Desugaring **/
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.0.4'
|
||||
|
||||
/** NewPipe libraries **/
|
||||
// You can use a local version by uncommenting a few lines in settings.gradle
|
||||
@@ -200,7 +198,7 @@ dependencies {
|
||||
// name and the commit hash with the commit hash of the (pushed) commit you want to test
|
||||
// This works thanks to JitPack: https://jitpack.io/
|
||||
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
|
||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:8495ad619e'
|
||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.24.0'
|
||||
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
|
||||
|
||||
/** Checkstyle **/
|
||||
@@ -208,31 +206,31 @@ dependencies {
|
||||
ktlint 'com.pinterest:ktlint:0.45.2'
|
||||
|
||||
/** Kotlin **/
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:${kotlin_version}"
|
||||
|
||||
/** AndroidX **/
|
||||
implementation 'androidx.appcompat:appcompat:1.5.1'
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.core:core-ktx:1.10.0'
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.4.1'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.6.2'
|
||||
implementation "androidx.lifecycle:lifecycle-livedata-ktx:${androidxLifecycleVersion}"
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${androidxLifecycleVersion}"
|
||||
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
|
||||
implementation 'androidx.media:media:1.6.0'
|
||||
implementation 'androidx.preference:preference:1.2.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
implementation 'androidx.media:media:1.7.0'
|
||||
implementation 'androidx.preference:preference:1.2.1'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||
implementation "androidx.room:room-runtime:${androidxRoomVersion}"
|
||||
implementation "androidx.room:room-rxjava3:${androidxRoomVersion}"
|
||||
kapt "androidx.room:room-compiler:${androidxRoomVersion}"
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
// Newer version specified to prevent accessibility regressions with RecyclerView, see:
|
||||
// https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
|
||||
implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}"
|
||||
implementation "androidx.work:work-rxjava3:${androidxWorkVersion}"
|
||||
implementation 'com.google.android.material:material:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
|
||||
/** Third-party libraries **/
|
||||
// Instance state boilerplate elimination
|
||||
@@ -240,10 +238,10 @@ dependencies {
|
||||
kapt "frankiesardo:icepick-processor:${icepickVersion}"
|
||||
|
||||
// HTML parser
|
||||
implementation "org.jsoup:jsoup:1.16.1"
|
||||
implementation "org.jsoup:jsoup:1.17.2"
|
||||
|
||||
// HTTP client
|
||||
implementation "com.squareup.okhttp3:okhttp:4.11.0"
|
||||
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
||||
|
||||
// Media player
|
||||
implementation "com.google.android.exoplayer:exoplayer-core:${exoPlayerVersion}"
|
||||
@@ -272,38 +270,37 @@ dependencies {
|
||||
implementation "io.noties.markwon:linkify:${markwonVersion}"
|
||||
|
||||
// Crash reporting
|
||||
implementation "ch.acra:acra-core:5.10.1"
|
||||
implementation "ch.acra:acra-core:5.11.3"
|
||||
|
||||
// Properly restarting
|
||||
implementation 'com.jakewharton:process-phoenix:2.1.2'
|
||||
|
||||
// Reactive extensions for Java VM
|
||||
implementation "io.reactivex.rxjava3:rxjava:3.1.6"
|
||||
implementation "io.reactivex.rxjava3:rxjava:3.1.8"
|
||||
implementation "io.reactivex.rxjava3:rxandroid:3.0.2"
|
||||
// RxJava binding APIs for Android UI widgets
|
||||
implementation "com.jakewharton.rxbinding4:rxbinding:4.0.0"
|
||||
|
||||
// Date and time formatting
|
||||
implementation "org.ocpsoft.prettytime:prettytime:5.0.6.Final"
|
||||
implementation "org.ocpsoft.prettytime:prettytime:5.0.7.Final"
|
||||
|
||||
/** Debugging **/
|
||||
// Memory leak detection
|
||||
implementation "com.squareup.leakcanary:leakcanary-object-watcher-android:${leakCanaryVersion}"
|
||||
implementation "com.squareup.leakcanary:plumber-android:${leakCanaryVersion}"
|
||||
debugImplementation "com.squareup.leakcanary:leakcanary-android:${leakCanaryVersion}"
|
||||
debugImplementation "com.squareup.leakcanary:leakcanary-object-watcher-android:${leakCanaryVersion}"
|
||||
debugImplementation "com.squareup.leakcanary:plumber-android:${leakCanaryVersion}"
|
||||
debugImplementation "com.squareup.leakcanary:leakcanary-android-core:${leakCanaryVersion}"
|
||||
// Debug bridge for Android
|
||||
debugImplementation "com.facebook.stetho:stetho:${stethoVersion}"
|
||||
debugImplementation "com.facebook.stetho:stetho-okhttp3:${stethoVersion}"
|
||||
|
||||
/** Testing **/
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation "org.mockito:mockito-core:${mockitoVersion}"
|
||||
testImplementation "org.mockito:mockito-inline:${mockitoVersion}"
|
||||
testImplementation 'org.mockito:mockito-core:5.6.0'
|
||||
|
||||
androidTestImplementation "androidx.test.ext:junit:1.1.5"
|
||||
androidTestImplementation "androidx.test:runner:1.5.2"
|
||||
androidTestImplementation "androidx.room:room-testing:${androidxRoomVersion}"
|
||||
androidTestImplementation "org.assertj:assertj-core:3.23.1"
|
||||
androidTestImplementation "org.assertj:assertj-core:3.24.2"
|
||||
}
|
||||
|
||||
static String getGitWorkingBranch() {
|
||||
|
||||
737
app/schemas/org.schabi.newpipe.database.AppDatabase/8.json
Normal file
737
app/schemas/org.schabi.newpipe.database.AppDatabase/8.json
Normal file
@@ -0,0 +1,737 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 8,
|
||||
"identityHash": "012fc8e7ad3333f1597347f34e76a513",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "subscriptions",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "service_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "avatarUrl",
|
||||
"columnName": "avatar_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriberCount",
|
||||
"columnName": "subscriber_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationMode",
|
||||
"columnName": "notification_mode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_subscriptions_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "search_history",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "creationDate",
|
||||
"columnName": "creation_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "service_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "search",
|
||||
"columnName": "search",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_search_history_search",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"search"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "streams",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "service_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "streamType",
|
||||
"columnName": "stream_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uploader",
|
||||
"columnName": "uploader",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uploaderUrl",
|
||||
"columnName": "uploader_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnail_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "viewCount",
|
||||
"columnName": "view_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "textualUploadDate",
|
||||
"columnName": "textual_upload_date",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "uploadDate",
|
||||
"columnName": "upload_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isUploadDateApproximation",
|
||||
"columnName": "is_upload_date_approximation",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_streams_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "stream_history",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "streamUid",
|
||||
"columnName": "stream_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accessDate",
|
||||
"columnName": "access_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "repeatCount",
|
||||
"columnName": "repeat_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"stream_id",
|
||||
"access_date"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_stream_history_stream_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "streams",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"stream_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "stream_state",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "streamUid",
|
||||
"columnName": "stream_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "progressMillis",
|
||||
"columnName": "progress_time",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "streams",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"stream_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "playlists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, `thumbnail_stream_id` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isThumbnailPermanent",
|
||||
"columnName": "is_thumbnail_permanent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailStreamId",
|
||||
"columnName": "thumbnail_stream_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_playlists_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`name`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "playlist_stream_join",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "playlistUid",
|
||||
"columnName": "playlist_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "streamUid",
|
||||
"columnName": "stream_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "index",
|
||||
"columnName": "join_index",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"playlist_id",
|
||||
"join_index"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_playlist_stream_join_playlist_id_join_index",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"playlist_id",
|
||||
"join_index"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)"
|
||||
},
|
||||
{
|
||||
"name": "index_playlist_stream_join_stream_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "playlists",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"playlist_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "streams",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"stream_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "remote_playlists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "service_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnail_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "uploader",
|
||||
"columnName": "uploader",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "streamCount",
|
||||
"columnName": "stream_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_remote_playlists_name",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)"
|
||||
},
|
||||
{
|
||||
"name": "index_remote_playlists_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "feed",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "streamId",
|
||||
"columnName": "stream_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscription_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"stream_id",
|
||||
"subscription_id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_feed_subscription_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "streams",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"stream_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "subscriptions",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"subscription_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "feed_group",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "icon",
|
||||
"columnName": "icon_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sort_order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_feed_group_sort_order",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sort_order"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "feed_group_subscription_join",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "feedGroupId",
|
||||
"columnName": "group_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscription_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"group_id",
|
||||
"subscription_id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_feed_group_subscription_join_subscription_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "feed_group",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"group_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "subscriptions",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"subscription_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "feed_last_updated",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscription_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastUpdated",
|
||||
"columnName": "last_updated",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "subscriptions",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"subscription_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '012fc8e7ad3333f1597347f34e76a513')"
|
||||
]
|
||||
}
|
||||
}
|
||||
730
app/schemas/org.schabi.newpipe.database.AppDatabase/9.json
Normal file
730
app/schemas/org.schabi.newpipe.database.AppDatabase/9.json
Normal file
@@ -0,0 +1,730 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 9,
|
||||
"identityHash": "7591e8039faa74d8c0517dc867af9d3e",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "subscriptions",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "service_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "avatarUrl",
|
||||
"columnName": "avatar_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriberCount",
|
||||
"columnName": "subscriber_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "notificationMode",
|
||||
"columnName": "notification_mode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_subscriptions_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "search_history",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "creationDate",
|
||||
"columnName": "creation_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "service_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "search",
|
||||
"columnName": "search",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_search_history_search",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"search"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "streams",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "service_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "streamType",
|
||||
"columnName": "stream_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uploader",
|
||||
"columnName": "uploader",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uploaderUrl",
|
||||
"columnName": "uploader_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnail_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "viewCount",
|
||||
"columnName": "view_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "textualUploadDate",
|
||||
"columnName": "textual_upload_date",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "uploadDate",
|
||||
"columnName": "upload_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isUploadDateApproximation",
|
||||
"columnName": "is_upload_date_approximation",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_streams_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "stream_history",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "streamUid",
|
||||
"columnName": "stream_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accessDate",
|
||||
"columnName": "access_date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "repeatCount",
|
||||
"columnName": "repeat_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"stream_id",
|
||||
"access_date"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_stream_history_stream_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "streams",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"stream_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "stream_state",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "streamUid",
|
||||
"columnName": "stream_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "progressMillis",
|
||||
"columnName": "progress_time",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "streams",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"stream_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "playlists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, `thumbnail_stream_id` INTEGER NOT NULL, `display_index` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isThumbnailPermanent",
|
||||
"columnName": "is_thumbnail_permanent",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailStreamId",
|
||||
"columnName": "thumbnail_stream_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayIndex",
|
||||
"columnName": "display_index",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "playlist_stream_join",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "playlistUid",
|
||||
"columnName": "playlist_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "streamUid",
|
||||
"columnName": "stream_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "index",
|
||||
"columnName": "join_index",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"playlist_id",
|
||||
"join_index"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_playlist_stream_join_playlist_id_join_index",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"playlist_id",
|
||||
"join_index"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)"
|
||||
},
|
||||
{
|
||||
"name": "index_playlist_stream_join_stream_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"stream_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "playlists",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"playlist_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "streams",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"stream_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "remote_playlists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `display_index` INTEGER NOT NULL, `stream_count` INTEGER)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serviceId",
|
||||
"columnName": "service_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "thumbnailUrl",
|
||||
"columnName": "thumbnail_url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "uploader",
|
||||
"columnName": "uploader",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayIndex",
|
||||
"columnName": "display_index",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "streamCount",
|
||||
"columnName": "stream_count",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_remote_playlists_service_id_url",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"service_id",
|
||||
"url"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "feed",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "streamId",
|
||||
"columnName": "stream_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscription_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"stream_id",
|
||||
"subscription_id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_feed_subscription_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "streams",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"stream_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "subscriptions",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"subscription_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "feed_group",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "uid",
|
||||
"columnName": "uid",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "icon",
|
||||
"columnName": "icon_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sort_order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_feed_group_sort_order",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"sort_order"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "feed_group_subscription_join",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "feedGroupId",
|
||||
"columnName": "group_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscription_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"group_id",
|
||||
"subscription_id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_feed_group_subscription_join_subscription_id",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "feed_group",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"group_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
},
|
||||
{
|
||||
"table": "subscriptions",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"subscription_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "feed_last_updated",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "subscriptionId",
|
||||
"columnName": "subscription_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastUpdated",
|
||||
"columnName": "last_updated",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"subscription_id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "subscriptions",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "CASCADE",
|
||||
"columns": [
|
||||
"subscription_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"uid"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7591e8039faa74d8c0517dc867af9d3e')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,18 @@ import android.content.ContentValues
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import androidx.room.Room
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
|
||||
import org.schabi.newpipe.extractor.ServiceList
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -21,20 +24,23 @@ class DatabaseMigrationTest {
|
||||
private const val DEFAULT_SERVICE_ID = 0
|
||||
private const val DEFAULT_URL = "https://www.youtube.com/watch?v=cDphUib5iG4"
|
||||
private const val DEFAULT_TITLE = "Test Title"
|
||||
private const val DEFAULT_NAME = "Test Name"
|
||||
private val DEFAULT_TYPE = StreamType.VIDEO_STREAM
|
||||
private const val DEFAULT_DURATION = 480L
|
||||
private const val DEFAULT_UPLOADER_NAME = "Uploader Test"
|
||||
private const val DEFAULT_THUMBNAIL = "https://example.com/example.jpg"
|
||||
|
||||
private const val DEFAULT_SECOND_SERVICE_ID = 0
|
||||
private const val DEFAULT_SECOND_SERVICE_ID = 1
|
||||
private const val DEFAULT_SECOND_URL = "https://www.youtube.com/watch?v=ncQU6iBn5Fc"
|
||||
|
||||
private const val DEFAULT_THIRD_SERVICE_ID = 2
|
||||
private const val DEFAULT_THIRD_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
||||
}
|
||||
|
||||
@get:Rule
|
||||
val testHelper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
AppDatabase::class.java.canonicalName,
|
||||
FrameworkSQLiteOpenHelperFactory()
|
||||
AppDatabase::class.java
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -108,6 +114,20 @@ class DatabaseMigrationTest {
|
||||
Migrations.MIGRATION_6_7
|
||||
)
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME,
|
||||
Migrations.DB_VER_8,
|
||||
true,
|
||||
Migrations.MIGRATION_7_8
|
||||
)
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME,
|
||||
Migrations.DB_VER_9,
|
||||
true,
|
||||
Migrations.MIGRATION_8_9
|
||||
)
|
||||
|
||||
val migratedDatabaseV3 = getMigratedDatabase()
|
||||
val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst()
|
||||
|
||||
@@ -142,6 +162,157 @@ class DatabaseMigrationTest {
|
||||
assertNull(secondStreamFromMigratedDatabase.isUploadDateApproximation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migrateDatabaseFrom7to8() {
|
||||
val databaseInV7 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_7)
|
||||
|
||||
val defaultSearch1 = " abc "
|
||||
val defaultSearch2 = " abc"
|
||||
|
||||
val serviceId = DEFAULT_SERVICE_ID // YouTube
|
||||
// Use id different to YouTube because two searches with the same query
|
||||
// but different service are considered not equal.
|
||||
val otherServiceId = ServiceList.SoundCloud.serviceId
|
||||
|
||||
databaseInV7.run {
|
||||
insert(
|
||||
"search_history", SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", serviceId)
|
||||
put("search", defaultSearch1)
|
||||
}
|
||||
)
|
||||
insert(
|
||||
"search_history", SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", serviceId)
|
||||
put("search", defaultSearch2)
|
||||
}
|
||||
)
|
||||
insert(
|
||||
"search_history", SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", otherServiceId)
|
||||
put("search", defaultSearch1)
|
||||
}
|
||||
)
|
||||
insert(
|
||||
"search_history", SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", otherServiceId)
|
||||
put("search", defaultSearch2)
|
||||
}
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME, Migrations.DB_VER_8,
|
||||
true, Migrations.MIGRATION_7_8
|
||||
)
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME, Migrations.DB_VER_9,
|
||||
true, Migrations.MIGRATION_8_9
|
||||
)
|
||||
|
||||
val migratedDatabaseV8 = getMigratedDatabase()
|
||||
val listFromDB = migratedDatabaseV8.searchHistoryDAO().all.blockingFirst()
|
||||
|
||||
assertEquals(2, listFromDB.size)
|
||||
assertEquals("abc", listFromDB[0].search)
|
||||
assertEquals("abc", listFromDB[1].search)
|
||||
assertNotEquals(listFromDB[0].serviceId, listFromDB[1].serviceId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migrateDatabaseFrom8to9() {
|
||||
val databaseInV8 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_8)
|
||||
|
||||
val localUid1: Long
|
||||
val localUid2: Long
|
||||
val remoteUid1: Long
|
||||
val remoteUid2: Long
|
||||
databaseInV8.run {
|
||||
localUid1 = insert(
|
||||
"playlists", SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("name", DEFAULT_NAME + "1")
|
||||
put("is_thumbnail_permanent", false)
|
||||
put("thumbnail_stream_id", -1)
|
||||
}
|
||||
)
|
||||
localUid2 = insert(
|
||||
"playlists", SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("name", DEFAULT_NAME + "2")
|
||||
put("is_thumbnail_permanent", false)
|
||||
put("thumbnail_stream_id", -1)
|
||||
}
|
||||
)
|
||||
delete(
|
||||
"playlists", "uid = ?",
|
||||
Array(1) { localUid1 }
|
||||
)
|
||||
remoteUid1 = insert(
|
||||
"remote_playlists", SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", DEFAULT_SERVICE_ID)
|
||||
put("url", DEFAULT_URL)
|
||||
}
|
||||
)
|
||||
remoteUid2 = insert(
|
||||
"remote_playlists", SQLiteDatabase.CONFLICT_FAIL,
|
||||
ContentValues().apply {
|
||||
put("service_id", DEFAULT_SECOND_SERVICE_ID)
|
||||
put("url", DEFAULT_SECOND_URL)
|
||||
}
|
||||
)
|
||||
delete(
|
||||
"remote_playlists", "uid = ?",
|
||||
Array(1) { remoteUid2 }
|
||||
)
|
||||
close()
|
||||
}
|
||||
|
||||
testHelper.runMigrationsAndValidate(
|
||||
AppDatabase.DATABASE_NAME,
|
||||
Migrations.DB_VER_9,
|
||||
true,
|
||||
Migrations.MIGRATION_8_9
|
||||
)
|
||||
|
||||
val migratedDatabaseV9 = getMigratedDatabase()
|
||||
var localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst()
|
||||
var remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst()
|
||||
|
||||
assertEquals(1, localListFromDB.size)
|
||||
assertEquals(localUid2, localListFromDB[0].uid)
|
||||
assertEquals(-1, localListFromDB[0].displayIndex)
|
||||
assertEquals(1, remoteListFromDB.size)
|
||||
assertEquals(remoteUid1, remoteListFromDB[0].uid)
|
||||
assertEquals(-1, remoteListFromDB[0].displayIndex)
|
||||
|
||||
val localUid3 = migratedDatabaseV9.playlistDAO().insert(
|
||||
PlaylistEntity(DEFAULT_NAME + "3", false, -1, -1)
|
||||
)
|
||||
val remoteUid3 = migratedDatabaseV9.playlistRemoteDAO().insert(
|
||||
PlaylistRemoteEntity(
|
||||
DEFAULT_THIRD_SERVICE_ID, DEFAULT_NAME, DEFAULT_THIRD_URL,
|
||||
DEFAULT_THUMBNAIL, DEFAULT_UPLOADER_NAME, -1, 10
|
||||
)
|
||||
)
|
||||
|
||||
localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst()
|
||||
remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst()
|
||||
assertEquals(2, localListFromDB.size)
|
||||
assertEquals(localUid3, localListFromDB[1].uid)
|
||||
assertEquals(-1, localListFromDB[1].displayIndex)
|
||||
assertEquals(2, remoteListFromDB.size)
|
||||
assertEquals(remoteUid3, remoteListFromDB[1].uid)
|
||||
assertEquals(-1, remoteListFromDB[1].displayIndex)
|
||||
}
|
||||
|
||||
private fun getMigratedDatabase(): AppDatabase {
|
||||
val database: AppDatabase = Room.databaseBuilder(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package org.schabi.newpipe.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.schabi.newpipe.database.feed.dao.FeedDAO
|
||||
import org.schabi.newpipe.database.feed.model.FeedEntity
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.stream.StreamWithState
|
||||
import org.schabi.newpipe.database.stream.dao.StreamDAO
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionDAO
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import org.schabi.newpipe.extractor.ServiceList
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import java.io.IOException
|
||||
import java.time.OffsetDateTime
|
||||
import kotlin.streams.toList
|
||||
|
||||
class FeedDAOTest {
|
||||
private lateinit var db: AppDatabase
|
||||
private lateinit var feedDAO: FeedDAO
|
||||
private lateinit var streamDAO: StreamDAO
|
||||
private lateinit var subscriptionDAO: SubscriptionDAO
|
||||
|
||||
private val serviceId = ServiceList.YouTube.serviceId
|
||||
|
||||
private val stream1 = StreamEntity(1, serviceId, "https://youtube.com/watch?v=1", "stream 1", StreamType.VIDEO_STREAM, 1000, "channel-1", "https://youtube.com/channel/1", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-01-01", OffsetDateTime.parse("2023-01-01T00:00:00Z"))
|
||||
private val stream2 = StreamEntity(2, serviceId, "https://youtube.com/watch?v=2", "stream 2", StreamType.VIDEO_STREAM, 1000, "channel-1", "https://youtube.com/channel/1", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-01-02", OffsetDateTime.parse("2023-01-02T00:00:00Z"))
|
||||
private val stream3 = StreamEntity(3, serviceId, "https://youtube.com/watch?v=3", "stream 3", StreamType.LIVE_STREAM, 1000, "channel-1", "https://youtube.com/channel/1", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-01-03", OffsetDateTime.parse("2023-01-03T00:00:00Z"))
|
||||
private val stream4 = StreamEntity(4, serviceId, "https://youtube.com/watch?v=4", "stream 4", StreamType.VIDEO_STREAM, 1000, "channel-2", "https://youtube.com/channel/2", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-08-10", OffsetDateTime.parse("2023-08-10T00:00:00Z"))
|
||||
private val stream5 = StreamEntity(5, serviceId, "https://youtube.com/watch?v=5", "stream 5", StreamType.VIDEO_STREAM, 1000, "channel-2", "https://youtube.com/channel/2", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-08-20", OffsetDateTime.parse("2023-08-20T00:00:00Z"))
|
||||
private val stream6 = StreamEntity(6, serviceId, "https://youtube.com/watch?v=6", "stream 6", StreamType.VIDEO_STREAM, 1000, "channel-3", "https://youtube.com/channel/3", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-09-01", OffsetDateTime.parse("2023-09-01T00:00:00Z"))
|
||||
private val stream7 = StreamEntity(7, serviceId, "https://youtube.com/watch?v=7", "stream 7", StreamType.VIDEO_STREAM, 1000, "channel-4", "https://youtube.com/channel/4", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-08-10", OffsetDateTime.parse("2023-08-10T00:00:00Z"))
|
||||
|
||||
private val allStreams = listOf(
|
||||
stream1, stream2, stream3, stream4, stream5, stream6, stream7
|
||||
)
|
||||
|
||||
@Before
|
||||
fun createDb() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(
|
||||
context, AppDatabase::class.java
|
||||
).build()
|
||||
feedDAO = db.feedDAO()
|
||||
streamDAO = db.streamDAO()
|
||||
subscriptionDAO = db.subscriptionDAO()
|
||||
}
|
||||
|
||||
@After
|
||||
@Throws(IOException::class)
|
||||
fun closeDb() {
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUnlinkStreamsOlderThan_KeepOne() {
|
||||
setupUnlinkDelete("2023-08-15T00:00:00Z")
|
||||
val streams = feedDAO.getStreams(
|
||||
FeedGroupEntity.GROUP_ALL_ID, includePlayed = true, includePartiallyPlayed = true, null
|
||||
)
|
||||
.blockingGet()
|
||||
val allowedStreams = listOf(stream3, stream5, stream6, stream7)
|
||||
assertEqual(streams, allowedStreams)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUnlinkStreamsOlderThan_KeepMultiple() {
|
||||
setupUnlinkDelete("2023-08-01T00:00:00Z")
|
||||
val streams = feedDAO.getStreams(
|
||||
FeedGroupEntity.GROUP_ALL_ID, includePlayed = true, includePartiallyPlayed = true, null
|
||||
)
|
||||
.blockingGet()
|
||||
val allowedStreams = listOf(stream3, stream4, stream5, stream6, stream7)
|
||||
assertEqual(streams, allowedStreams)
|
||||
}
|
||||
|
||||
private fun assertEqual(streams: List<StreamWithState>?, allowedStreams: List<StreamEntity>) {
|
||||
assertNotNull(streams)
|
||||
assertEquals(
|
||||
allowedStreams,
|
||||
streams!!
|
||||
.map { it.stream }
|
||||
.sortedBy { it.uid }
|
||||
.toList()
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupUnlinkDelete(time: String) {
|
||||
clearAndFillTables()
|
||||
Single.fromCallable {
|
||||
feedDAO.unlinkStreamsOlderThan(OffsetDateTime.parse(time))
|
||||
}.blockingSubscribe()
|
||||
Single.fromCallable {
|
||||
streamDAO.deleteOrphans()
|
||||
}.blockingSubscribe()
|
||||
}
|
||||
|
||||
private fun clearAndFillTables() {
|
||||
db.clearAllTables()
|
||||
streamDAO.insertAll(allStreams)
|
||||
subscriptionDAO.insertAll(
|
||||
listOf(
|
||||
SubscriptionEntity.from(ChannelInfo(serviceId, "1", "https://youtube.com/channel/1", "https://youtube.com/channel/1", "channel-1")),
|
||||
SubscriptionEntity.from(ChannelInfo(serviceId, "2", "https://youtube.com/channel/2", "https://youtube.com/channel/2", "channel-2")),
|
||||
SubscriptionEntity.from(ChannelInfo(serviceId, "3", "https://youtube.com/channel/3", "https://youtube.com/channel/3", "channel-3")),
|
||||
SubscriptionEntity.from(ChannelInfo(serviceId, "4", "https://youtube.com/channel/4", "https://youtube.com/channel/4", "channel-4")),
|
||||
)
|
||||
)
|
||||
feedDAO.insertAll(
|
||||
listOf(
|
||||
FeedEntity(1, 1),
|
||||
FeedEntity(2, 1),
|
||||
FeedEntity(3, 1),
|
||||
FeedEntity(4, 2),
|
||||
FeedEntity(5, 2),
|
||||
FeedEntity(6, 3),
|
||||
FeedEntity(7, 4),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package org.schabi.newpipe.local.subscription;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import androidx.test.core.app.ApplicationProvider;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.schabi.newpipe.database.AppDatabase;
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.testUtil.TestDatabase;
|
||||
import org.schabi.newpipe.testUtil.TrampolineSchedulerRule;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
public class SubscriptionManagerTest {
|
||||
private AppDatabase database;
|
||||
private SubscriptionManager manager;
|
||||
|
||||
@Rule
|
||||
public TrampolineSchedulerRule trampolineScheduler = new TrampolineSchedulerRule();
|
||||
|
||||
|
||||
private SubscriptionEntity getAssertOneSubscriptionEntity() {
|
||||
final List<SubscriptionEntity> entities = manager
|
||||
.getSubscriptions(FeedGroupEntity.GROUP_ALL_ID, "", false)
|
||||
.blockingFirst();
|
||||
assertEquals(1, entities.size());
|
||||
return entities.get(0);
|
||||
}
|
||||
|
||||
|
||||
@Before
|
||||
public void setup() {
|
||||
database = TestDatabase.Companion.createReplacingNewPipeDatabase();
|
||||
manager = new SubscriptionManager(ApplicationProvider.getApplicationContext());
|
||||
}
|
||||
|
||||
@After
|
||||
public void cleanUp() {
|
||||
database.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testInsert() throws ExtractionException, IOException {
|
||||
final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/3blue1brown");
|
||||
final SubscriptionEntity subscription = SubscriptionEntity.from(info);
|
||||
|
||||
manager.insertSubscription(subscription);
|
||||
final SubscriptionEntity readSubscription = getAssertOneSubscriptionEntity();
|
||||
|
||||
// the uid has changed, since the uid is chosen upon inserting, but the rest should match
|
||||
assertEquals(subscription.getServiceId(), readSubscription.getServiceId());
|
||||
assertEquals(subscription.getUrl(), readSubscription.getUrl());
|
||||
assertEquals(subscription.getName(), readSubscription.getName());
|
||||
assertEquals(subscription.getAvatarUrl(), readSubscription.getAvatarUrl());
|
||||
assertEquals(subscription.getSubscriberCount(), readSubscription.getSubscriberCount());
|
||||
assertEquals(subscription.getDescription(), readSubscription.getDescription());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUpdateNotificationMode() throws ExtractionException, IOException {
|
||||
final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/veritasium");
|
||||
final SubscriptionEntity subscription = SubscriptionEntity.from(info);
|
||||
subscription.setNotificationMode(0);
|
||||
|
||||
manager.insertSubscription(subscription);
|
||||
manager.updateNotificationMode(subscription.getServiceId(), subscription.getUrl(), 1)
|
||||
.blockingAwait();
|
||||
final SubscriptionEntity anotherSubscription = getAssertOneSubscriptionEntity();
|
||||
|
||||
assertEquals(0, subscription.getNotificationMode());
|
||||
assertEquals(subscription.getUrl(), anotherSubscription.getUrl());
|
||||
assertEquals(1, anotherSubscription.getNotificationMode());
|
||||
}
|
||||
}
|
||||
@@ -12,15 +12,21 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import androidx.test.internal.runner.junit4.statement.UiThreadStatement
|
||||
import org.junit.Assert
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.MediaFormat
|
||||
import org.schabi.newpipe.extractor.downloader.Response
|
||||
import org.schabi.newpipe.extractor.stream.AudioStream
|
||||
import org.schabi.newpipe.extractor.stream.Stream
|
||||
import org.schabi.newpipe.extractor.stream.SubtitlesStream
|
||||
import org.schabi.newpipe.extractor.stream.VideoStream
|
||||
import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper
|
||||
|
||||
@MediumTest
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -84,7 +90,7 @@ class StreamItemAdapterTest {
|
||||
@Test
|
||||
fun subtitleStreams_noIcon() {
|
||||
val adapter = StreamItemAdapter<SubtitlesStream, Stream>(
|
||||
StreamItemAdapter.StreamSizeWrapper(
|
||||
StreamItemAdapter.StreamInfoWrapper(
|
||||
(0 until 5).map {
|
||||
SubtitlesStream.Builder()
|
||||
.setContent("https://example.com", true)
|
||||
@@ -105,7 +111,7 @@ class StreamItemAdapterTest {
|
||||
@Test
|
||||
fun audioStreams_noIcon() {
|
||||
val adapter = StreamItemAdapter<AudioStream, Stream>(
|
||||
StreamItemAdapter.StreamSizeWrapper(
|
||||
StreamItemAdapter.StreamInfoWrapper(
|
||||
(0 until 5).map {
|
||||
AudioStream.Builder()
|
||||
.setId(Stream.ID_UNKNOWN)
|
||||
@@ -123,12 +129,109 @@ class StreamItemAdapterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retrieveMediaFormatFromFileTypeHeaders() {
|
||||
val streams = getIncompleteAudioStreams(5)
|
||||
val wrapper = StreamInfoWrapper(streams, context)
|
||||
val retrieveMediaFormat = { stream: AudioStream, response: Response ->
|
||||
StreamInfoWrapper.retrieveMediaFormatFromFileTypeHeaders(stream, wrapper, response)
|
||||
}
|
||||
val helper = AssertionHelper(streams, wrapper, retrieveMediaFormat)
|
||||
|
||||
helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "mp3"))), 0)
|
||||
helper.assertInvalidResponse(getResponse(mapOf(Pair("file-type", "mp0"))), 1)
|
||||
|
||||
helper.assertValidResponse(getResponse(mapOf(Pair("x-amz-meta-file-type", "aiff"))), 2, MediaFormat.AIFF)
|
||||
helper.assertValidResponse(getResponse(mapOf(Pair("file-type", "mp3"))), 3, MediaFormat.MP3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retrieveMediaFormatFromContentDispositionHeader() {
|
||||
val streams = getIncompleteAudioStreams(11)
|
||||
val wrapper = StreamInfoWrapper(streams, context)
|
||||
val retrieveMediaFormat = { stream: AudioStream, response: Response ->
|
||||
StreamInfoWrapper.retrieveMediaFormatFromContentDispositionHeader(stream, wrapper, response)
|
||||
}
|
||||
val helper = AssertionHelper(streams, wrapper, retrieveMediaFormat)
|
||||
|
||||
helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "mp3"))), 0)
|
||||
helper.assertInvalidResponse(
|
||||
getResponse(mapOf(Pair("Content-Disposition", "filename=\"train.png\""))), 1
|
||||
)
|
||||
helper.assertInvalidResponse(
|
||||
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"data.csv\""))), 2
|
||||
)
|
||||
helper.assertInvalidResponse(
|
||||
getResponse(mapOf(Pair("Content-Disposition", "form-data; filename=\"data.csv\""))), 3
|
||||
)
|
||||
helper.assertInvalidResponse(
|
||||
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"fieldName\"; filename*=\"filename.jpg\""))), 4
|
||||
)
|
||||
|
||||
helper.assertValidResponse(
|
||||
getResponse(mapOf(Pair("Content-Disposition", "filename=\"train.ogg\""))),
|
||||
5, MediaFormat.OGG
|
||||
)
|
||||
helper.assertValidResponse(
|
||||
getResponse(mapOf(Pair("Content-Disposition", "some-form-data; filename=\"audio.flac\""))),
|
||||
6, MediaFormat.FLAC
|
||||
)
|
||||
helper.assertValidResponse(
|
||||
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.aiff\"; filename=\"audio.aiff\""))),
|
||||
7, MediaFormat.AIFF
|
||||
)
|
||||
helper.assertValidResponse(
|
||||
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"alien?\"; filename*=UTF-8''%CE%B1%CE%BB%CE%B9%CF%B5%CE%BD.m4a"))),
|
||||
8, MediaFormat.M4A
|
||||
)
|
||||
helper.assertValidResponse(
|
||||
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.mp3\"; filename=\"audio.opus\"; filename*=UTF-8''alien.opus"))),
|
||||
9, MediaFormat.OPUS
|
||||
)
|
||||
helper.assertValidResponse(
|
||||
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.mp3\"; filename=\"audio.opus\"; filename*=\"UTF-8''alien.opus\""))),
|
||||
10, MediaFormat.OPUS
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retrieveMediaFormatFromContentTypeHeader() {
|
||||
val streams = getIncompleteAudioStreams(12)
|
||||
val wrapper = StreamInfoWrapper(streams, context)
|
||||
val retrieveMediaFormat = { stream: AudioStream, response: Response ->
|
||||
StreamInfoWrapper.retrieveMediaFormatFromContentTypeHeader(stream, wrapper, response)
|
||||
}
|
||||
val helper = AssertionHelper(streams, wrapper, retrieveMediaFormat)
|
||||
|
||||
helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "984501"))), 0)
|
||||
helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "audio/xyz"))), 1)
|
||||
helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "mp3"))), 2)
|
||||
helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "mp3"))), 3)
|
||||
helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "audio/mpeg"))), 4)
|
||||
helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "audio/aif"))), 5)
|
||||
helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "whatever"))), 6)
|
||||
helper.assertInvalidResponse(getResponse(mapOf()), 7)
|
||||
|
||||
helper.assertValidResponse(
|
||||
getResponse(mapOf(Pair("Content-Type", "audio/flac"))), 8, MediaFormat.FLAC
|
||||
)
|
||||
helper.assertValidResponse(
|
||||
getResponse(mapOf(Pair("Content-Type", "audio/wav"))), 9, MediaFormat.WAV
|
||||
)
|
||||
helper.assertValidResponse(
|
||||
getResponse(mapOf(Pair("Content-Type", "audio/opus"))), 10, MediaFormat.OPUS
|
||||
)
|
||||
helper.assertValidResponse(
|
||||
getResponse(mapOf(Pair("Content-Type", "audio/aiff"))), 11, MediaFormat.AIFF
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return a list of video streams, in which their video only property mirrors the provided
|
||||
* [videoOnly] vararg.
|
||||
*/
|
||||
private fun getVideoStreams(vararg videoOnly: Boolean) =
|
||||
StreamItemAdapter.StreamSizeWrapper(
|
||||
StreamItemAdapter.StreamInfoWrapper(
|
||||
videoOnly.map {
|
||||
VideoStream.Builder()
|
||||
.setId(Stream.ID_UNKNOWN)
|
||||
@@ -161,6 +264,19 @@ class StreamItemAdapterTest {
|
||||
}
|
||||
)
|
||||
|
||||
private fun getIncompleteAudioStreams(size: Int): List<AudioStream> {
|
||||
val list = ArrayList<AudioStream>(size)
|
||||
for (i in 1..size) {
|
||||
list.add(
|
||||
AudioStream.Builder()
|
||||
.setId(Stream.ID_UNKNOWN)
|
||||
.setContent("https://example.com/$i", true)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the item at [position] in the [spinner] has the correct icon visibility when
|
||||
* it is shown in normal mode (selected) and in dropdown mode (user is choosing one of a list).
|
||||
@@ -196,11 +312,56 @@ class StreamItemAdapterTest {
|
||||
streams.forEachIndexed { index, stream ->
|
||||
val secondaryStreamHelper: SecondaryStreamHelper<T>? = stream?.let {
|
||||
SecondaryStreamHelper(
|
||||
StreamItemAdapter.StreamSizeWrapper(streams, context),
|
||||
StreamItemAdapter.StreamInfoWrapper(streams, context),
|
||||
it
|
||||
)
|
||||
}
|
||||
put(index, secondaryStreamHelper)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getResponse(headers: Map<String, String>): Response {
|
||||
val listHeaders = HashMap<String, List<String>>()
|
||||
headers.forEach { entry ->
|
||||
listHeaders[entry.key] = listOf(entry.value)
|
||||
}
|
||||
return Response(200, null, listHeaders, "", "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class for assertion related to extractions of [MediaFormat]s.
|
||||
*/
|
||||
class AssertionHelper<T : Stream>(
|
||||
private val streams: List<T>,
|
||||
private val wrapper: StreamInfoWrapper<T>,
|
||||
private val retrieveMediaFormat: (stream: T, response: Response) -> Boolean
|
||||
) {
|
||||
|
||||
/**
|
||||
* Assert that an invalid response does not result in wrongly extracted [MediaFormat].
|
||||
*/
|
||||
fun assertInvalidResponse(
|
||||
response: Response,
|
||||
index: Int
|
||||
) {
|
||||
assertFalse(
|
||||
"invalid header returns valid value", retrieveMediaFormat(streams[index], response)
|
||||
)
|
||||
assertNull("Media format extracted although stated otherwise", wrapper.getFormat(index))
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that a valid response results in correctly extracted and handled [MediaFormat].
|
||||
*/
|
||||
fun assertValidResponse(
|
||||
response: Response,
|
||||
index: Int,
|
||||
format: MediaFormat
|
||||
) {
|
||||
assertTrue(
|
||||
"header was not recognized", retrieveMediaFormat(streams[index], response)
|
||||
)
|
||||
assertEquals("Wrong media format extracted", format, wrapper.getFormat(index))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.schabi.newpipe
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.facebook.stetho.Stetho
|
||||
import com.facebook.stetho.okhttp3.StethoInterceptor
|
||||
import leakcanary.AppWatcher
|
||||
import leakcanary.LeakCanary
|
||||
import okhttp3.OkHttpClient
|
||||
import org.schabi.newpipe.extractor.downloader.Downloader
|
||||
@@ -13,8 +12,6 @@ class DebugApp : App() {
|
||||
super.onCreate()
|
||||
initStetho()
|
||||
|
||||
// Give each object 10 seconds to be GC'ed, before LeakCanary gets nosy on it
|
||||
AppWatcher.config = AppWatcher.config.copy(watchDurationMillis = 10000)
|
||||
LeakCanary.config = LeakCanary.config.copy(
|
||||
dumpHeap = PreferenceManager
|
||||
.getDefaultSharedPreferences(this).getBoolean(
|
||||
|
||||
@@ -25,6 +25,7 @@ import android.view.ViewGroup;
|
||||
import androidx.annotation.IntDef;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.os.BundleCompat;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
import androidx.viewpager.widget.PagerAdapter;
|
||||
|
||||
@@ -284,7 +285,7 @@ public abstract class FragmentStatePagerAdapterMenuWorkaround extends PagerAdapt
|
||||
Bundle state = null;
|
||||
if (!mSavedState.isEmpty()) {
|
||||
state = new Bundle();
|
||||
state.putParcelableArray("states", mSavedState.toArray(new Fragment.SavedState[0]));
|
||||
state.putParcelableArrayList("states", mSavedState);
|
||||
}
|
||||
for (int i = 0; i < mFragments.size(); i++) {
|
||||
final Fragment f = mFragments.get(i);
|
||||
@@ -311,13 +312,12 @@ public abstract class FragmentStatePagerAdapterMenuWorkaround extends PagerAdapt
|
||||
if (state != null) {
|
||||
final Bundle bundle = (Bundle) state;
|
||||
bundle.setClassLoader(loader);
|
||||
final Parcelable[] fss = bundle.getParcelableArray("states");
|
||||
final var states = BundleCompat.getParcelableArrayList(bundle, "states",
|
||||
Fragment.SavedState.class);
|
||||
mSavedState.clear();
|
||||
mFragments.clear();
|
||||
if (fss != null) {
|
||||
for (final Parcelable parcelable : fss) {
|
||||
mSavedState.add((Fragment.SavedState) parcelable);
|
||||
}
|
||||
if (states != null) {
|
||||
mSavedState.addAll(states);
|
||||
}
|
||||
final Iterable<String> keys = bundle.keySet();
|
||||
for (final String key : keys) {
|
||||
|
||||
@@ -20,9 +20,11 @@ import org.schabi.newpipe.extractor.downloader.Downloader;
|
||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||
import org.schabi.newpipe.settings.NewPipeSettings;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
import org.schabi.newpipe.util.StateSaver;
|
||||
import org.schabi.newpipe.util.image.PreferredImageQuality;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InterruptedIOException;
|
||||
@@ -58,6 +60,8 @@ import io.reactivex.rxjava3.plugins.RxJavaPlugins;
|
||||
public class App extends Application {
|
||||
public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID;
|
||||
private static final String TAG = App.class.toString();
|
||||
|
||||
private boolean isFirstRun = false;
|
||||
private static App app;
|
||||
|
||||
@NonNull
|
||||
@@ -83,7 +87,13 @@ public class App extends Application {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize settings first because others inits can use its values
|
||||
// check if the last used preference version is set
|
||||
// to determine whether this is the first app run
|
||||
final int lastUsedPrefVersion = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
.getInt(getString(R.string.last_used_preferences_version), -1);
|
||||
isFirstRun = lastUsedPrefVersion == -1;
|
||||
|
||||
// Initialize settings first because other initializations can use its values
|
||||
NewPipeSettings.initSettings(this);
|
||||
|
||||
NewPipe.init(getDownloader(),
|
||||
@@ -99,8 +109,9 @@ public class App extends Application {
|
||||
// Initialize image loader
|
||||
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
|
||||
PicassoHelper.init(this);
|
||||
PicassoHelper.setShouldLoadImages(
|
||||
prefs.getBoolean(getString(R.string.download_thumbnail_key), true));
|
||||
ImageStrategy.setPreferredImageQuality(PreferredImageQuality.fromPreferenceKey(this,
|
||||
prefs.getString(getString(R.string.image_quality_key),
|
||||
getString(R.string.image_quality_default))));
|
||||
PicassoHelper.setIndicatorsEnabled(MainActivity.DEBUG
|
||||
&& prefs.getBoolean(getString(R.string.show_image_indicators_key), false));
|
||||
|
||||
@@ -252,4 +263,7 @@ public class App extends Application {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isFirstRun() {
|
||||
return isFirstRun;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.fragment.app.FragmentManager;
|
||||
|
||||
import icepick.Icepick;
|
||||
import icepick.State;
|
||||
import leakcanary.AppWatcher;
|
||||
|
||||
public abstract class BaseFragment extends Fragment {
|
||||
protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode());
|
||||
@@ -77,20 +76,33 @@ public abstract class BaseFragment extends Fragment {
|
||||
protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
|
||||
AppWatcher.INSTANCE.getObjectWatcher().watch(this);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Init
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
/**
|
||||
* This method is called in {@link #onViewCreated(View, Bundle)} to initialize the views.
|
||||
*
|
||||
* <p>
|
||||
* {@link #initListeners()} is called after this method to initialize the corresponding
|
||||
* listeners.
|
||||
* </p>
|
||||
* @param rootView The inflated view for this fragment
|
||||
* (provided by {@link #onViewCreated(View, Bundle)})
|
||||
* @param savedInstanceState The saved state of this fragment
|
||||
* (provided by {@link #onViewCreated(View, Bundle)})
|
||||
*/
|
||||
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the listeners for this fragment.
|
||||
*
|
||||
* <p>
|
||||
* This method is called after {@link #initViews(View, Bundle)}
|
||||
* in {@link #onViewCreated(View, Bundle)}.
|
||||
* </p>
|
||||
*/
|
||||
protected void initListeners() {
|
||||
}
|
||||
|
||||
@@ -108,9 +120,20 @@ public abstract class BaseFragment extends Fragment {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the root fragment by looping through all of the parent fragments. The root fragment
|
||||
* is supposed to be {@link org.schabi.newpipe.fragments.MainFragment}, and is the fragment that
|
||||
* handles keeping the backstack of opened fragments in NewPipe, and also the player bottom
|
||||
* sheet. This function therefore returns the fragment manager of said fragment.
|
||||
*
|
||||
* @return the fragment manager of the root fragment, i.e.
|
||||
* {@link org.schabi.newpipe.fragments.MainFragment}
|
||||
*/
|
||||
protected FragmentManager getFM() {
|
||||
return getParentFragment() == null
|
||||
? getFragmentManager()
|
||||
: getParentFragment().getFragmentManager();
|
||||
Fragment current = this;
|
||||
while (current.getParentFragment() != null) {
|
||||
current = current.getParentFragment();
|
||||
}
|
||||
return current.getFragmentManager();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ 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;
|
||||
@@ -51,6 +52,7 @@ import androidx.core.app.ActivityCompat;
|
||||
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;
|
||||
|
||||
@@ -63,19 +65,21 @@ import org.schabi.newpipe.databinding.InstanceSpinnerLayoutBinding;
|
||||
import org.schabi.newpipe.databinding.ToolbarLayoutBinding;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.ServiceList;
|
||||
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;
|
||||
import org.schabi.newpipe.player.event.OnKeyDownListener;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.settings.UpdateSettingsFragment;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.KioskTranslator;
|
||||
@@ -83,6 +87,7 @@ import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.PeertubeHelper;
|
||||
import org.schabi.newpipe.util.PermissionHelper;
|
||||
import org.schabi.newpipe.util.ReleaseVersionUtil;
|
||||
import org.schabi.newpipe.util.SerializedCache;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
import org.schabi.newpipe.util.StateSaver;
|
||||
@@ -164,6 +169,11 @@ public class MainActivity extends AppCompatActivity {
|
||||
// if this is enabled by the user.
|
||||
NotificationWorker.initialize(this);
|
||||
}
|
||||
if (!UpdateSettingsFragment.wasUserAskedForConsent(this)
|
||||
&& !App.getApp().isFirstRun()
|
||||
&& ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
|
||||
UpdateSettingsFragment.askForConsentToUpdateChecks(this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -173,7 +183,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
final App app = App.getApp();
|
||||
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
|
||||
|
||||
if (prefs.getBoolean(app.getString(R.string.update_app_key), true)) {
|
||||
if (prefs.getBoolean(app.getString(R.string.update_app_key), false)
|
||||
&& prefs.getBoolean(app.getString(R.string.update_check_consent_key), false)) {
|
||||
// Start the worker which is checking all conditions
|
||||
// and eventually searching for a new version.
|
||||
NewVersionWorker.enqueueNewVersionCheckingWork(app, false);
|
||||
@@ -220,14 +231,14 @@ public class MainActivity extends AppCompatActivity {
|
||||
final int currentServiceId = ServiceHelper.getSelectedServiceId(this);
|
||||
final StreamingService service = NewPipe.getService(currentServiceId);
|
||||
|
||||
int kioskId = 0;
|
||||
int kioskMenuItemId = 0;
|
||||
|
||||
for (final String ks : service.getKioskList().getAvailableKiosks()) {
|
||||
drawerLayoutBinding.navigation.getMenu()
|
||||
.add(R.id.menu_tabs_group, kioskId, 0, KioskTranslator
|
||||
.add(R.id.menu_tabs_group, kioskMenuItemId, 0, KioskTranslator
|
||||
.getTranslatedKioskName(ks, this))
|
||||
.setIcon(KioskTranslator.getKioskIcon(ks));
|
||||
kioskId++;
|
||||
kioskMenuItemId++;
|
||||
}
|
||||
|
||||
drawerLayoutBinding.navigation.getMenu()
|
||||
@@ -259,15 +270,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
private boolean drawerItemSelected(final MenuItem item) {
|
||||
switch (item.getGroupId()) {
|
||||
case R.id.menu_services_group:
|
||||
if (item.getItemId() == ServiceList.PeerTube.getServiceId()
|
||||
&& DeviceUtils.isTv(getApplicationContext())
|
||||
&& !item.isActionViewExpanded()) {
|
||||
((Spinner) item.getActionView()).performClick();
|
||||
return true;
|
||||
} else {
|
||||
changeService(item);
|
||||
break;
|
||||
}
|
||||
changeService(item);
|
||||
break;
|
||||
case R.id.menu_tabs_group:
|
||||
try {
|
||||
tabSelected(item);
|
||||
@@ -314,20 +318,16 @@ public class MainActivity extends AppCompatActivity {
|
||||
NavigationHelper.openStatisticFragment(getSupportFragmentManager());
|
||||
break;
|
||||
default:
|
||||
final int currentServiceId = ServiceHelper.getSelectedServiceId(this);
|
||||
final StreamingService service = NewPipe.getService(currentServiceId);
|
||||
String serviceName = "";
|
||||
|
||||
int kioskId = 0;
|
||||
for (final String ks : service.getKioskList().getAvailableKiosks()) {
|
||||
if (kioskId == item.getItemId()) {
|
||||
serviceName = ks;
|
||||
final StreamingService currentService = ServiceHelper.getSelectedService(this);
|
||||
int kioskMenuItemId = 0;
|
||||
for (final String kioskId : currentService.getKioskList().getAvailableKiosks()) {
|
||||
if (kioskMenuItemId == item.getItemId()) {
|
||||
NavigationHelper.openKioskFragment(getSupportFragmentManager(),
|
||||
currentService.getServiceId(), kioskId);
|
||||
break;
|
||||
}
|
||||
kioskId++;
|
||||
kioskMenuItemId++;
|
||||
}
|
||||
|
||||
NavigationHelper.openKioskFragment(getSupportFragmentManager(), currentServiceId,
|
||||
serviceName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -391,8 +391,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
.add(R.id.menu_services_group, s.getServiceId(), ORDER, title)
|
||||
.setIcon(ServiceHelper.getIcon(s.getServiceId()));
|
||||
|
||||
// PeerTube specifics
|
||||
if (s == ServiceList.PeerTube) {
|
||||
// peertube specifics
|
||||
if (s.getServiceId() == 3) {
|
||||
enhancePeertubeMenu(menuItem);
|
||||
}
|
||||
}
|
||||
@@ -558,14 +558,21 @@ public class MainActivity extends AppCompatActivity {
|
||||
// interacts with a fragment inside fragment_holder so all back presses should be
|
||||
// handled by it
|
||||
if (bottomSheetHiddenOrCollapsed()) {
|
||||
final Fragment fragment = getSupportFragmentManager()
|
||||
.findFragmentById(R.id.fragment_holder);
|
||||
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);
|
||||
}
|
||||
|
||||
} else {
|
||||
@@ -641,10 +648,17 @@ public class MainActivity extends AppCompatActivity {
|
||||
* </pre>
|
||||
*/
|
||||
private void onHomeButtonPressed() {
|
||||
// If search fragment wasn't found in the backstack...
|
||||
if (!NavigationHelper.tryGotoSearchFragment(getSupportFragmentManager())) {
|
||||
// ...go to the main fragment
|
||||
NavigationHelper.gotoMainFragment(getSupportFragmentManager());
|
||||
final FragmentManager fm = getSupportFragmentManager();
|
||||
final Fragment fragment = fm.findFragmentById(R.id.fragment_holder);
|
||||
|
||||
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 search fragment wasn't found in the backstack go to the main fragment
|
||||
NavigationHelper.gotoMainFragment(fm);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,6 +854,68 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -7,6 +7,8 @@ import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_5_6;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_6_7;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_7_8;
|
||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_8_9;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
@@ -27,7 +29,7 @@ public final class NewPipeDatabase {
|
||||
return Room
|
||||
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
|
||||
MIGRATION_5_6, MIGRATION_6_7)
|
||||
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9)
|
||||
.build();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,7 @@ import com.grack.nanojson.JsonParser
|
||||
import com.grack.nanojson.JsonParserException
|
||||
import org.schabi.newpipe.extractor.downloader.Response
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||
import org.schabi.newpipe.util.ReleaseVersionUtil.coerceUpdateCheckExpiry
|
||||
import org.schabi.newpipe.util.ReleaseVersionUtil.isLastUpdateCheckExpired
|
||||
import org.schabi.newpipe.util.ReleaseVersionUtil.isReleaseApk
|
||||
import org.schabi.newpipe.util.ReleaseVersionUtil
|
||||
import java.io.IOException
|
||||
|
||||
class NewVersionWorker(
|
||||
@@ -84,7 +82,7 @@ class NewVersionWorker(
|
||||
@Throws(IOException::class, ReCaptchaException::class)
|
||||
private fun checkNewVersion() {
|
||||
// Check if the current apk is a github one or not.
|
||||
if (!isReleaseApk()) {
|
||||
if (!ReleaseVersionUtil.isReleaseApk) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -93,7 +91,7 @@ class NewVersionWorker(
|
||||
// Check if the last request has happened a certain time ago
|
||||
// to reduce the number of API requests.
|
||||
val expiry = prefs.getLong(applicationContext.getString(R.string.update_expiry_key), 0)
|
||||
if (!isLastUpdateCheckExpired(expiry)) {
|
||||
if (!ReleaseVersionUtil.isLastUpdateCheckExpired(expiry)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -108,7 +106,7 @@ class NewVersionWorker(
|
||||
try {
|
||||
// Store a timestamp which needs to be exceeded,
|
||||
// before a new request to the API is made.
|
||||
val newExpiry = coerceUpdateCheckExpiry(response.getHeader("expires"))
|
||||
val newExpiry = ReleaseVersionUtil.coerceUpdateCheckExpiry(response.getHeader("expires"))
|
||||
prefs.edit {
|
||||
putLong(applicationContext.getString(R.string.update_expiry_key), newExpiry)
|
||||
}
|
||||
@@ -120,13 +118,13 @@ class NewVersionWorker(
|
||||
|
||||
// Parse the json from the response.
|
||||
try {
|
||||
val githubStableObject = JsonParser.`object`()
|
||||
val newpipeVersionInfo = JsonParser.`object`()
|
||||
.from(response.responseBody()).getObject("flavors")
|
||||
.getObject("github").getObject("stable")
|
||||
.getObject("newpipe")
|
||||
|
||||
val versionName = githubStableObject.getString("version")
|
||||
val versionCode = githubStableObject.getInt("version_code")
|
||||
val apkLocationUrl = githubStableObject.getString("apk")
|
||||
val versionName = newpipeVersionInfo.getString("version")
|
||||
val versionCode = newpipeVersionInfo.getInt("version_code")
|
||||
val apkLocationUrl = newpipeVersionInfo.getString("apk")
|
||||
compareAppVersionAndShowNotification(versionName, apkLocationUrl, versionCode)
|
||||
} catch (e: JsonParserException) {
|
||||
// Most likely something is wrong in data received from NEWPIPE_API_URL.
|
||||
|
||||
@@ -75,7 +75,7 @@ public final class QueueItemMenuUtil {
|
||||
return true;
|
||||
case R.id.menu_item_share:
|
||||
shareText(context, item.getTitle(), item.getUrl(),
|
||||
item.getThumbnailUrl());
|
||||
item.getThumbnails());
|
||||
return true;
|
||||
case R.id.menu_item_download:
|
||||
fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(),
|
||||
|
||||
@@ -45,6 +45,7 @@ import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||
import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
|
||||
import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding;
|
||||
import org.schabi.newpipe.download.DownloadDialog;
|
||||
import org.schabi.newpipe.download.LoadingDialog;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.ReCaptchaActivity;
|
||||
@@ -64,6 +65,7 @@ import org.schabi.newpipe.extractor.exceptions.PrivateContentException;
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
|
||||
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException;
|
||||
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException;
|
||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||
@@ -71,10 +73,11 @@ import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||
import org.schabi.newpipe.player.PlayerType;
|
||||
import org.schabi.newpipe.player.helper.PlayerHelper;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
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.Constants;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
@@ -789,10 +792,10 @@ public class RouterActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
}, () -> {
|
||||
}, () ->
|
||||
// this branch is executed if there is no activity context
|
||||
inFlight(false);
|
||||
});
|
||||
inFlight(false)
|
||||
);
|
||||
}
|
||||
|
||||
<T> Single<T> pleaseWait(final Single<T> single) {
|
||||
@@ -812,19 +815,24 @@ public class RouterActivity extends AppCompatActivity {
|
||||
@SuppressLint("CheckResult")
|
||||
private void openDownloadDialog(final int currentServiceId, final String currentUrl) {
|
||||
inFlight(true);
|
||||
final LoadingDialog loadingDialog = new LoadingDialog(R.string.loading_metadata_title);
|
||||
loadingDialog.show(getParentFragmentManager(), "loadingDialog");
|
||||
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.compose(this::pleaseWait)
|
||||
.subscribe(result ->
|
||||
runOnVisible(ctx -> {
|
||||
loadingDialog.dismiss();
|
||||
final FragmentManager fm = ctx.getSupportFragmentManager();
|
||||
final DownloadDialog downloadDialog = new DownloadDialog(ctx, result);
|
||||
// dismiss listener to be handled by FragmentManager
|
||||
downloadDialog.show(fm, "downloadDialog");
|
||||
}
|
||||
), throwable -> runOnVisible(ctx ->
|
||||
((RouterActivity) ctx).showUnsupportedUrlDialog(currentUrl))));
|
||||
), throwable -> runOnVisible(ctx -> {
|
||||
loadingDialog.dismiss();
|
||||
((RouterActivity) ctx).showUnsupportedUrlDialog(currentUrl);
|
||||
})));
|
||||
}
|
||||
|
||||
private void openAddToPlaylistDialog(final int currentServiceId, final String currentUrl) {
|
||||
@@ -1016,7 +1024,16 @@ public class RouterActivity extends AppCompatActivity {
|
||||
}
|
||||
playQueue = new SinglePlayQueue((StreamInfo) info);
|
||||
} else if (info instanceof ChannelInfo) {
|
||||
playQueue = new ChannelPlayQueue((ChannelInfo) info);
|
||||
final Optional<ListLinkHandler> playableTab = ((ChannelInfo) info).getTabs()
|
||||
.stream()
|
||||
.filter(ChannelTabHelper::isStreamsTab)
|
||||
.findFirst();
|
||||
|
||||
if (playableTab.isPresent()) {
|
||||
playQueue = new ChannelTabPlayQueue(info.getServiceId(), playableTab.get());
|
||||
} else {
|
||||
return; // there is no playable tab
|
||||
}
|
||||
} else if (info instanceof PlaylistInfo) {
|
||||
playQueue = new PlaylistPlayQueue((PlaylistInfo) info);
|
||||
} else {
|
||||
|
||||
@@ -116,7 +116,7 @@ class AboutActivity : AppCompatActivity() {
|
||||
/**
|
||||
* List of all software components.
|
||||
*/
|
||||
private val SOFTWARE_COMPONENTS = arrayOf(
|
||||
private val SOFTWARE_COMPONENTS = arrayListOf(
|
||||
SoftwareComponent(
|
||||
"ACRA", "2013", "Kevin Gaudin",
|
||||
"https://github.com/ACRA/acra", StandardLicenses.APACHE2
|
||||
|
||||
@@ -1,30 +1,40 @@
|
||||
package org.schabi.newpipe.about
|
||||
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.databinding.FragmentLicensesBinding
|
||||
import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding
|
||||
import org.schabi.newpipe.ktx.parcelableArrayList
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
|
||||
/**
|
||||
* Fragment containing the software licenses.
|
||||
*/
|
||||
class LicenseFragment : Fragment() {
|
||||
private lateinit var softwareComponents: Array<SoftwareComponent>
|
||||
private var activeLicense: License? = null
|
||||
private lateinit var softwareComponents: List<SoftwareComponent>
|
||||
private var activeSoftwareComponent: SoftwareComponent? = null
|
||||
private val compositeDisposable = CompositeDisposable()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
softwareComponents = arguments?.getParcelableArray(ARG_COMPONENTS) as Array<SoftwareComponent>
|
||||
activeLicense = savedInstanceState?.getSerializable(LICENSE_KEY) as? License
|
||||
// Sort components by name
|
||||
softwareComponents.sortBy { it.name }
|
||||
softwareComponents = arguments?.parcelableArrayList<SoftwareComponent>(ARG_COMPONENTS)!!
|
||||
.sortedBy { it.name } // Sort components by name
|
||||
activeSoftwareComponent = savedInstanceState?.getSerializable(SOFTWARE_COMPONENT_KEY) as? SoftwareComponent
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@@ -39,9 +49,8 @@ class LicenseFragment : Fragment() {
|
||||
): View {
|
||||
val binding = FragmentLicensesBinding.inflate(inflater, container, false)
|
||||
binding.licensesAppReadLicense.setOnClickListener {
|
||||
activeLicense = StandardLicenses.GPL3
|
||||
compositeDisposable.add(
|
||||
showLicense(activity, StandardLicenses.GPL3)
|
||||
showLicense(NEWPIPE_SOFTWARE_COMPONENT)
|
||||
)
|
||||
}
|
||||
for (component in softwareComponents) {
|
||||
@@ -57,27 +66,72 @@ class LicenseFragment : Fragment() {
|
||||
val root: View = componentBinding.root
|
||||
root.tag = component
|
||||
root.setOnClickListener {
|
||||
activeLicense = component.license
|
||||
compositeDisposable.add(
|
||||
showLicense(activity, component)
|
||||
showLicense(component)
|
||||
)
|
||||
}
|
||||
binding.licensesSoftwareComponents.addView(root)
|
||||
registerForContextMenu(root)
|
||||
}
|
||||
activeLicense?.let { compositeDisposable.add(showLicense(activity, it)) }
|
||||
activeSoftwareComponent?.let { compositeDisposable.add(showLicense(it)) }
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(savedInstanceState: Bundle) {
|
||||
super.onSaveInstanceState(savedInstanceState)
|
||||
activeLicense?.let { savedInstanceState.putSerializable(LICENSE_KEY, it) }
|
||||
activeSoftwareComponent?.let { savedInstanceState.putSerializable(SOFTWARE_COMPONENT_KEY, it) }
|
||||
}
|
||||
|
||||
private fun showLicense(
|
||||
softwareComponent: SoftwareComponent
|
||||
): Disposable {
|
||||
return if (context == null) {
|
||||
Disposable.empty()
|
||||
} else {
|
||||
val context = requireContext()
|
||||
activeSoftwareComponent = softwareComponent
|
||||
Observable.fromCallable { getFormattedLicense(context, softwareComponent.license) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { formattedLicense ->
|
||||
val webViewData = Base64.encodeToString(
|
||||
formattedLicense.toByteArray(), Base64.NO_PADDING
|
||||
)
|
||||
val webView = WebView(context)
|
||||
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
|
||||
|
||||
Localization.assureCorrectAppLanguage(context)
|
||||
val builder = AlertDialog.Builder(requireContext())
|
||||
.setTitle(softwareComponent.name)
|
||||
.setView(webView)
|
||||
.setOnCancelListener { activeSoftwareComponent = null }
|
||||
.setOnDismissListener { activeSoftwareComponent = null }
|
||||
.setPositiveButton(R.string.done) { dialog, _ -> dialog.dismiss() }
|
||||
|
||||
if (softwareComponent != NEWPIPE_SOFTWARE_COMPONENT) {
|
||||
builder.setNeutralButton(R.string.open_website_license) { _, _ ->
|
||||
ShareUtils.openUrlInApp(requireContext(), softwareComponent.link)
|
||||
}
|
||||
}
|
||||
|
||||
builder.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_COMPONENTS = "components"
|
||||
private const val LICENSE_KEY = "ACTIVE_LICENSE"
|
||||
fun newInstance(softwareComponents: Array<SoftwareComponent>): LicenseFragment {
|
||||
private const val SOFTWARE_COMPONENT_KEY = "ACTIVE_SOFTWARE_COMPONENT"
|
||||
private val NEWPIPE_SOFTWARE_COMPONENT = SoftwareComponent(
|
||||
"NewPipe",
|
||||
"2014-2023",
|
||||
"Team NewPipe",
|
||||
"https://newpipe.net/",
|
||||
StandardLicenses.GPL3,
|
||||
BuildConfig.VERSION_NAME
|
||||
)
|
||||
|
||||
fun newInstance(softwareComponents: ArrayList<SoftwareComponent>): LicenseFragment {
|
||||
val fragment = LicenseFragment()
|
||||
fragment.arguments = bundleOf(ARG_COMPONENTS to softwareComponents)
|
||||
return fragment
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
package org.schabi.newpipe.about
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.webkit.WebView
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.ThemeHelper
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
@@ -20,7 +11,7 @@ import java.io.IOException
|
||||
* @return String which contains a HTML formatted license page
|
||||
* styled according to the context's theme
|
||||
*/
|
||||
private fun getFormattedLicense(context: Context, license: License): String {
|
||||
fun getFormattedLicense(context: Context, license: License): String {
|
||||
try {
|
||||
return context.assets.open(license.filename).bufferedReader().use { it.readText() }
|
||||
// split the HTML file and insert the stylesheet into the HEAD of the file
|
||||
@@ -34,7 +25,7 @@ private fun getFormattedLicense(context: Context, license: License): String {
|
||||
* @param context the Android context
|
||||
* @return String which is a CSS stylesheet according to the context's theme
|
||||
*/
|
||||
private fun getLicenseStylesheet(context: Context): String {
|
||||
fun getLicenseStylesheet(context: Context): String {
|
||||
val isLightTheme = ThemeHelper.isLightThemeSelected(context)
|
||||
val licenseBackgroundColor = getHexRGBColor(
|
||||
context, if (isLightTheme) R.color.light_license_background_color else R.color.dark_license_background_color
|
||||
@@ -56,48 +47,6 @@ private fun getLicenseStylesheet(context: Context): String {
|
||||
* @param color the color number from R.color
|
||||
* @return a six characters long String with hexadecimal RGB values
|
||||
*/
|
||||
private fun getHexRGBColor(context: Context, color: Int): String {
|
||||
fun getHexRGBColor(context: Context, color: Int): String {
|
||||
return context.getString(color).substring(3)
|
||||
}
|
||||
|
||||
fun showLicense(context: Context?, component: SoftwareComponent): Disposable {
|
||||
return showLicense(context, component.license) {
|
||||
setPositiveButton(R.string.dismiss) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
}
|
||||
setNeutralButton(R.string.open_website_license) { _, _ ->
|
||||
ShareUtils.openUrlInApp(context!!, component.link)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showLicense(context: Context?, license: License) = showLicense(context, license) {
|
||||
setPositiveButton(R.string.ok) { dialog, _ -> dialog.dismiss() }
|
||||
}
|
||||
|
||||
private fun showLicense(
|
||||
context: Context?,
|
||||
license: License,
|
||||
block: AlertDialog.Builder.() -> AlertDialog.Builder
|
||||
): Disposable {
|
||||
return if (context == null) {
|
||||
Disposable.empty()
|
||||
} else {
|
||||
Observable.fromCallable { getFormattedLicense(context, license) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { formattedLicense ->
|
||||
val webViewData =
|
||||
Base64.encodeToString(formattedLicense.toByteArray(), Base64.NO_PADDING)
|
||||
val webView = WebView(context)
|
||||
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
|
||||
|
||||
Localization.assureCorrectAppLanguage(context)
|
||||
AlertDialog.Builder(context)
|
||||
.setTitle(license.name)
|
||||
.setView(webView)
|
||||
.block()
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.schabi.newpipe.about
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.io.Serializable
|
||||
|
||||
@Parcelize
|
||||
class SoftwareComponent
|
||||
@@ -13,4 +14,4 @@ constructor(
|
||||
val link: String,
|
||||
val license: License,
|
||||
val version: String? = null
|
||||
) : Parcelable
|
||||
) : Parcelable, Serializable
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package org.schabi.newpipe.database;
|
||||
|
||||
import static org.schabi.newpipe.database.Migrations.DB_VER_7;
|
||||
import static org.schabi.newpipe.database.Migrations.DB_VER_9;
|
||||
|
||||
import androidx.room.Database;
|
||||
import androidx.room.RoomDatabase;
|
||||
@@ -38,7 +38,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
|
||||
FeedLastUpdatedEntity.class
|
||||
},
|
||||
version = DB_VER_7
|
||||
version = DB_VER_9
|
||||
)
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
public static final String DATABASE_NAME = "newpipe.db";
|
||||
|
||||
@@ -7,7 +7,7 @@ import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
object Converters {
|
||||
class Converters {
|
||||
/**
|
||||
* Convert a long value to a [OffsetDateTime].
|
||||
*
|
||||
@@ -47,6 +47,6 @@ object Converters {
|
||||
|
||||
@TypeConverter
|
||||
fun feedGroupIconOf(id: Int): FeedGroupIcon {
|
||||
return FeedGroupIcon.values().first { it.id == id }
|
||||
return FeedGroupIcon.entries.first { it.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ public final class Migrations {
|
||||
public static final int DB_VER_5 = 5;
|
||||
public static final int DB_VER_6 = 6;
|
||||
public static final int DB_VER_7 = 7;
|
||||
public static final int DB_VER_8 = 8;
|
||||
public static final int DB_VER_9 = 9;
|
||||
|
||||
private static final String TAG = Migrations.class.getName();
|
||||
public static final boolean DEBUG = MainActivity.DEBUG;
|
||||
@@ -186,7 +188,7 @@ public final class Migrations {
|
||||
@Override
|
||||
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||
database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` "
|
||||
+ "INTEGER NOT NULL DEFAULT 0");
|
||||
+ "INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -235,6 +237,71 @@ public final class Migrations {
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_7_8 = new Migration(DB_VER_7, DB_VER_8) {
|
||||
@Override
|
||||
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||
database.execSQL("DELETE FROM search_history WHERE id NOT IN (SELECT id FROM (SELECT "
|
||||
+ "MIN(id) as id FROM search_history GROUP BY trim(search), service_id ) tmp)");
|
||||
database.execSQL("UPDATE search_history SET search = trim(search)");
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_8_9 = new Migration(DB_VER_8, DB_VER_9) {
|
||||
@Override
|
||||
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||
try {
|
||||
database.beginTransaction();
|
||||
|
||||
// Update playlists.
|
||||
// Create a temp table to initialize display_index.
|
||||
database.execSQL("CREATE TABLE `playlists_tmp` "
|
||||
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
|
||||
+ "`name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, "
|
||||
+ "`thumbnail_stream_id` INTEGER NOT NULL, "
|
||||
+ "`display_index` INTEGER NOT NULL)");
|
||||
database.execSQL("INSERT INTO `playlists_tmp` "
|
||||
+ "(`uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, "
|
||||
+ "`display_index`) "
|
||||
+ "SELECT `uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, "
|
||||
+ "-1 "
|
||||
+ "FROM `playlists`");
|
||||
|
||||
// Replace the old table, note that this also removes the index on the name which
|
||||
// we don't need anymore.
|
||||
database.execSQL("DROP TABLE `playlists`");
|
||||
database.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`");
|
||||
|
||||
|
||||
// Update remote_playlists.
|
||||
// Create a temp table to initialize display_index.
|
||||
database.execSQL("CREATE TABLE `remote_playlists_tmp` "
|
||||
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
|
||||
+ "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, "
|
||||
+ "`thumbnail_url` TEXT, `uploader` TEXT, "
|
||||
+ "`display_index` INTEGER NOT NULL,"
|
||||
+ "`stream_count` INTEGER)");
|
||||
database.execSQL("INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, "
|
||||
+ "`name`, `url`, `thumbnail_url`, `uploader`, `display_index`, "
|
||||
+ "`stream_count`)"
|
||||
+ "SELECT `uid`, `service_id`, `name`, `url`, `thumbnail_url`, `uploader`, "
|
||||
+ "-1, `stream_count` FROM `remote_playlists`");
|
||||
|
||||
// Replace the old table, note that this also removes the index on the name which
|
||||
// we don't need anymore.
|
||||
database.execSQL("DROP TABLE `remote_playlists`");
|
||||
database.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`");
|
||||
|
||||
// Create index on the new table.
|
||||
database.execSQL("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` "
|
||||
+ "ON `remote_playlists` (`service_id`, `url`)");
|
||||
|
||||
database.setTransactionSuccessful();
|
||||
} finally {
|
||||
database.endTransaction();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private Migrations() {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,18 +93,30 @@ abstract class FeedDAO {
|
||||
uploadDateBefore: OffsetDateTime?
|
||||
): Maybe<List<StreamWithState>>
|
||||
|
||||
/**
|
||||
* Remove links to streams that are older than the given date
|
||||
* **but keep at least one stream per uploader**.
|
||||
*
|
||||
* One stream per uploader is kept because it is needed as reference
|
||||
* when fetching new streams to check if they are new or not.
|
||||
* @param offsetDateTime the newest date to keep, older streams are removed
|
||||
*/
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM feed WHERE
|
||||
|
||||
feed.stream_id IN (
|
||||
SELECT s.uid FROM streams s
|
||||
|
||||
INNER JOIN feed f
|
||||
ON s.uid = f.stream_id
|
||||
|
||||
WHERE s.upload_date < :offsetDateTime
|
||||
)
|
||||
DELETE FROM feed
|
||||
WHERE feed.stream_id IN (SELECT uid from (
|
||||
SELECT s.uid,
|
||||
(SELECT MAX(upload_date)
|
||||
FROM streams s1
|
||||
INNER JOIN feed f1
|
||||
ON s1.uid = f1.stream_id
|
||||
WHERE f1.subscription_id = f.subscription_id) max_upload_date
|
||||
FROM streams s
|
||||
INNER JOIN feed f
|
||||
ON s.uid = f.stream_id
|
||||
|
||||
WHERE s.upload_date < :offsetDateTime
|
||||
AND s.upload_date <> max_upload_date))
|
||||
"""
|
||||
)
|
||||
abstract fun unlinkStreamsOlderThan(offsetDateTime: OffsetDateTime)
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.schabi.newpipe.database.feed.model
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.ForeignKey.CASCADE
|
||||
import androidx.room.Index
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.FEED_GROUP_SUBSCRIPTION_TABLE
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.GROUP_ID
|
||||
@@ -19,14 +18,14 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
entity = FeedGroupEntity::class,
|
||||
parentColumns = [FeedGroupEntity.ID],
|
||||
childColumns = [GROUP_ID],
|
||||
onDelete = CASCADE, onUpdate = CASCADE, deferred = true
|
||||
onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true
|
||||
),
|
||||
|
||||
ForeignKey(
|
||||
entity = SubscriptionEntity::class,
|
||||
parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID],
|
||||
childColumns = [SUBSCRIPTION_ID],
|
||||
onDelete = CASCADE, onUpdate = CASCADE, deferred = true
|
||||
onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -13,12 +13,17 @@ public class PlaylistDuplicatesEntry extends PlaylistMetadataEntry {
|
||||
@ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED)
|
||||
public final long timesStreamIsContained;
|
||||
|
||||
@SuppressWarnings("checkstyle:ParameterNumber")
|
||||
public PlaylistDuplicatesEntry(final long uid,
|
||||
final String name,
|
||||
final String thumbnailUrl,
|
||||
final boolean isThumbnailPermanent,
|
||||
final long thumbnailStreamId,
|
||||
final long displayIndex,
|
||||
final long streamCount,
|
||||
final long timesStreamIsContained) {
|
||||
super(uid, name, thumbnailUrl, streamCount);
|
||||
super(uid, name, thumbnailUrl, isThumbnailPermanent, thumbnailStreamId, displayIndex,
|
||||
streamCount);
|
||||
this.timesStreamIsContained = timesStreamIsContained;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
package org.schabi.newpipe.database.playlist;
|
||||
|
||||
import org.schabi.newpipe.database.LocalItem;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public interface PlaylistLocalItem extends LocalItem {
|
||||
String getOrderingName();
|
||||
|
||||
static List<PlaylistLocalItem> merge(
|
||||
final List<PlaylistMetadataEntry> localPlaylists,
|
||||
final List<PlaylistRemoteEntity> remotePlaylists) {
|
||||
return Stream.concat(localPlaylists.stream(), remotePlaylists.stream())
|
||||
.sorted(Comparator.comparing(PlaylistLocalItem::getOrderingName,
|
||||
Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
long getDisplayIndex();
|
||||
|
||||
long getUid();
|
||||
|
||||
void setDisplayIndex(long displayIndex);
|
||||
}
|
||||
|
||||
@@ -2,27 +2,40 @@ package org.schabi.newpipe.database.playlist;
|
||||
|
||||
import androidx.room.ColumnInfo;
|
||||
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
|
||||
|
||||
public class PlaylistMetadataEntry implements PlaylistLocalItem {
|
||||
public static final String PLAYLIST_STREAM_COUNT = "streamCount";
|
||||
|
||||
@ColumnInfo(name = PLAYLIST_ID)
|
||||
public final long uid;
|
||||
private final long uid;
|
||||
@ColumnInfo(name = PLAYLIST_NAME)
|
||||
public final String name;
|
||||
@ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT)
|
||||
private final boolean isThumbnailPermanent;
|
||||
@ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID)
|
||||
private final long thumbnailStreamId;
|
||||
@ColumnInfo(name = PLAYLIST_THUMBNAIL_URL)
|
||||
public final String thumbnailUrl;
|
||||
@ColumnInfo(name = PLAYLIST_DISPLAY_INDEX)
|
||||
private long displayIndex;
|
||||
@ColumnInfo(name = PLAYLIST_STREAM_COUNT)
|
||||
public final long streamCount;
|
||||
|
||||
public PlaylistMetadataEntry(final long uid, final String name, final String thumbnailUrl,
|
||||
final long streamCount) {
|
||||
final boolean isThumbnailPermanent, final long thumbnailStreamId,
|
||||
final long displayIndex, final long streamCount) {
|
||||
this.uid = uid;
|
||||
this.name = name;
|
||||
this.thumbnailUrl = thumbnailUrl;
|
||||
this.isThumbnailPermanent = isThumbnailPermanent;
|
||||
this.thumbnailStreamId = thumbnailStreamId;
|
||||
this.displayIndex = displayIndex;
|
||||
this.streamCount = streamCount;
|
||||
}
|
||||
|
||||
@@ -35,4 +48,27 @@ public class PlaylistMetadataEntry implements PlaylistLocalItem {
|
||||
public String getOrderingName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public boolean isThumbnailPermanent() {
|
||||
return isThumbnailPermanent;
|
||||
}
|
||||
|
||||
public long getThumbnailStreamId() {
|
||||
return thumbnailStreamId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getDisplayIndex() {
|
||||
return displayIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getUid() {
|
||||
return uid;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setDisplayIndex(final long displayIndex) {
|
||||
this.displayIndex = displayIndex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
|
||||
data class PlaylistStreamEntry(
|
||||
@Embedded
|
||||
@@ -28,7 +29,7 @@ data class PlaylistStreamEntry(
|
||||
item.duration = streamEntity.duration
|
||||
item.uploaderName = streamEntity.uploader
|
||||
item.uploaderUrl = streamEntity.uploaderUrl
|
||||
item.thumbnailUrl = streamEntity.thumbnailUrl
|
||||
item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.schabi.newpipe.database.playlist.dao;
|
||||
|
||||
import androidx.room.Dao;
|
||||
import androidx.room.Query;
|
||||
import androidx.room.Transaction;
|
||||
|
||||
import org.schabi.newpipe.database.BasicDAO;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
|
||||
@@ -36,4 +37,17 @@ public interface PlaylistDAO extends BasicDAO<PlaylistEntity> {
|
||||
|
||||
@Query("SELECT COUNT(*) FROM " + PLAYLIST_TABLE)
|
||||
Flowable<Long> getCount();
|
||||
|
||||
@Transaction
|
||||
default long upsertPlaylist(final PlaylistEntity playlist) {
|
||||
final long playlistId = playlist.getUid();
|
||||
|
||||
if (playlistId == -1) {
|
||||
// This situation is probably impossible.
|
||||
return insert(playlist);
|
||||
} else {
|
||||
update(playlist);
|
||||
return playlistId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_DISPLAY_INDEX;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE;
|
||||
@@ -31,10 +32,18 @@ public interface PlaylistRemoteDAO extends BasicDAO<PlaylistRemoteEntity> {
|
||||
+ " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
|
||||
Flowable<List<PlaylistRemoteEntity>> listByService(int serviceId);
|
||||
|
||||
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
|
||||
+ REMOTE_PLAYLIST_ID + " = :playlistId")
|
||||
Flowable<List<PlaylistRemoteEntity>> getPlaylist(long playlistId);
|
||||
|
||||
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
|
||||
+ REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
|
||||
Flowable<List<PlaylistRemoteEntity>> getPlaylist(long serviceId, String url);
|
||||
|
||||
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE
|
||||
+ " ORDER BY " + REMOTE_PLAYLIST_DISPLAY_INDEX)
|
||||
Flowable<List<PlaylistRemoteEntity>> getPlaylists();
|
||||
|
||||
@Query("SELECT " + REMOTE_PLAYLIST_ID + " FROM " + REMOTE_PLAYLIST_TABLE
|
||||
+ " WHERE " + REMOTE_PLAYLIST_URL + " = :url "
|
||||
+ "AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
|
||||
|
||||
@@ -18,10 +18,12 @@ import io.reactivex.rxjava3.core.Flowable;
|
||||
|
||||
import static org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry.PLAYLIST_TIMES_STREAM_IS_CONTAINED;
|
||||
import static org.schabi.newpipe.database.playlist.PlaylistMetadataEntry.PLAYLIST_STREAM_COUNT;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.DEFAULT_THUMBNAIL;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX;
|
||||
@@ -91,7 +93,9 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
|
||||
Flowable<List<PlaylistStreamEntry>> getOrderedStreamsOf(long playlistId);
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ","
|
||||
@Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", "
|
||||
+ PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", "
|
||||
+ PLAYLIST_DISPLAY_INDEX + ", "
|
||||
|
||||
+ " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = "
|
||||
+ PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'"
|
||||
@@ -105,7 +109,7 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
|
||||
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
|
||||
+ " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID
|
||||
+ " GROUP BY " + PLAYLIST_ID
|
||||
+ " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC")
|
||||
+ " ORDER BY " + PLAYLIST_DISPLAY_INDEX)
|
||||
Flowable<List<PlaylistMetadataEntry>> getPlaylistMetadata();
|
||||
|
||||
@RewriteQueriesToDropUnusedColumns
|
||||
@@ -126,8 +130,9 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
|
||||
Flowable<List<PlaylistStreamEntry>> getStreamsWithoutDuplicates(long playlistId);
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT " + PLAYLIST_TABLE + "." + PLAYLIST_ID + ", "
|
||||
+ PLAYLIST_NAME + ", "
|
||||
@Query("SELECT " + PLAYLIST_TABLE + "." + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", "
|
||||
+ PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", "
|
||||
+ PLAYLIST_DISPLAY_INDEX + ", "
|
||||
|
||||
+ " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = "
|
||||
+ PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'"
|
||||
@@ -149,6 +154,6 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
|
||||
+ " AND :streamUrl = :streamUrl"
|
||||
|
||||
+ " GROUP BY " + JOIN_PLAYLIST_ID
|
||||
+ " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC")
|
||||
+ " ORDER BY " + PLAYLIST_DISPLAY_INDEX)
|
||||
Flowable<List<PlaylistDuplicatesEntry>> getPlaylistDuplicatesMetadata(String streamUrl);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,15 @@ package org.schabi.newpipe.database.playlist.model;
|
||||
|
||||
import androidx.room.ColumnInfo;
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.Index;
|
||||
import androidx.room.Ignore;
|
||||
import androidx.room.PrimaryKey;
|
||||
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
|
||||
|
||||
@Entity(tableName = PLAYLIST_TABLE,
|
||||
indices = {@Index(value = {PLAYLIST_NAME})})
|
||||
@Entity(tableName = PLAYLIST_TABLE)
|
||||
public class PlaylistEntity {
|
||||
|
||||
public static final String DEFAULT_THUMBNAIL = "drawable://"
|
||||
@@ -22,6 +21,7 @@ public class PlaylistEntity {
|
||||
public static final String PLAYLIST_ID = "uid";
|
||||
public static final String PLAYLIST_NAME = "name";
|
||||
public static final String PLAYLIST_THUMBNAIL_URL = "thumbnail_url";
|
||||
public static final String PLAYLIST_DISPLAY_INDEX = "display_index";
|
||||
public static final String PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent";
|
||||
public static final String PLAYLIST_THUMBNAIL_STREAM_ID = "thumbnail_stream_id";
|
||||
|
||||
@@ -38,11 +38,24 @@ public class PlaylistEntity {
|
||||
@ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID)
|
||||
private long thumbnailStreamId;
|
||||
|
||||
@ColumnInfo(name = PLAYLIST_DISPLAY_INDEX)
|
||||
private long displayIndex;
|
||||
|
||||
public PlaylistEntity(final String name, final boolean isThumbnailPermanent,
|
||||
final long thumbnailStreamId) {
|
||||
final long thumbnailStreamId, final long displayIndex) {
|
||||
this.name = name;
|
||||
this.isThumbnailPermanent = isThumbnailPermanent;
|
||||
this.thumbnailStreamId = thumbnailStreamId;
|
||||
this.displayIndex = displayIndex;
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public PlaylistEntity(final PlaylistMetadataEntry item) {
|
||||
this.uid = item.getUid();
|
||||
this.name = item.name;
|
||||
this.isThumbnailPermanent = item.isThumbnailPermanent();
|
||||
this.thumbnailStreamId = item.getThumbnailStreamId();
|
||||
this.displayIndex = item.getDisplayIndex();
|
||||
}
|
||||
|
||||
public long getUid() {
|
||||
@@ -77,4 +90,11 @@ public class PlaylistEntity {
|
||||
this.isThumbnailPermanent = isThumbnailSet;
|
||||
}
|
||||
|
||||
public long getDisplayIndex() {
|
||||
return displayIndex;
|
||||
}
|
||||
|
||||
public void setDisplayIndex(final long displayIndex) {
|
||||
this.displayIndex = displayIndex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.room.PrimaryKey;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistLocalItem;
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
|
||||
import static org.schabi.newpipe.database.LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_NAME;
|
||||
@@ -20,7 +21,6 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.RE
|
||||
|
||||
@Entity(tableName = REMOTE_PLAYLIST_TABLE,
|
||||
indices = {
|
||||
@Index(value = {REMOTE_PLAYLIST_NAME}),
|
||||
@Index(value = {REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL}, unique = true)
|
||||
})
|
||||
public class PlaylistRemoteEntity implements PlaylistLocalItem {
|
||||
@@ -31,6 +31,7 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
|
||||
public static final String REMOTE_PLAYLIST_URL = "url";
|
||||
public static final String REMOTE_PLAYLIST_THUMBNAIL_URL = "thumbnail_url";
|
||||
public static final String REMOTE_PLAYLIST_UPLOADER_NAME = "uploader";
|
||||
public static final String REMOTE_PLAYLIST_DISPLAY_INDEX = "display_index";
|
||||
public static final String REMOTE_PLAYLIST_STREAM_COUNT = "stream_count";
|
||||
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@@ -52,6 +53,9 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME)
|
||||
private String uploader;
|
||||
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_DISPLAY_INDEX)
|
||||
private long displayIndex = -1; // Make sure the new item is on the top
|
||||
|
||||
@ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT)
|
||||
private Long streamCount;
|
||||
|
||||
@@ -66,11 +70,25 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
|
||||
this.streamCount = streamCount;
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public PlaylistRemoteEntity(final int serviceId, final String name, final String url,
|
||||
final String thumbnailUrl, final String uploader,
|
||||
final long displayIndex, final Long streamCount) {
|
||||
this.serviceId = serviceId;
|
||||
this.name = name;
|
||||
this.url = url;
|
||||
this.thumbnailUrl = thumbnailUrl;
|
||||
this.uploader = uploader;
|
||||
this.displayIndex = displayIndex;
|
||||
this.streamCount = streamCount;
|
||||
}
|
||||
|
||||
@Ignore
|
||||
public PlaylistRemoteEntity(final PlaylistInfo info) {
|
||||
this(info.getServiceId(), info.getName(), info.getUrl(),
|
||||
info.getThumbnailUrl() == null
|
||||
? info.getUploaderAvatarUrl() : info.getThumbnailUrl(),
|
||||
// use uploader avatar when no thumbnail is available
|
||||
ImageStrategy.imageListToDbUrl(info.getThumbnails().isEmpty()
|
||||
? info.getUploaderAvatars() : info.getThumbnails()),
|
||||
info.getUploaderName(), info.getStreamCount());
|
||||
}
|
||||
|
||||
@@ -84,10 +102,14 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
|
||||
&& getStreamCount() == info.getStreamCount()
|
||||
&& TextUtils.equals(getName(), info.getName())
|
||||
&& TextUtils.equals(getUrl(), info.getUrl())
|
||||
&& TextUtils.equals(getThumbnailUrl(), info.getThumbnailUrl())
|
||||
// we want to update the local playlist data even when either the remote thumbnail
|
||||
// URL changes, or the preferred image quality setting is changed by the user
|
||||
&& TextUtils.equals(getThumbnailUrl(),
|
||||
ImageStrategy.imageListToDbUrl(info.getThumbnails()))
|
||||
&& TextUtils.equals(getUploader(), info.getUploaderName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getUid() {
|
||||
return uid;
|
||||
}
|
||||
@@ -136,6 +158,16 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
|
||||
this.uploader = uploader;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getDisplayIndex() {
|
||||
return displayIndex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setDisplayIndex(final long displayIndex) {
|
||||
this.displayIndex = displayIndex;
|
||||
}
|
||||
|
||||
public Long getStreamCount() {
|
||||
return streamCount;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.schabi.newpipe.database.history.model.StreamHistoryEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.util.image.ImageStrategy
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
class StreamStatisticsEntry(
|
||||
@@ -30,7 +31,7 @@ class StreamStatisticsEntry(
|
||||
item.duration = streamEntity.duration
|
||||
item.uploaderName = streamEntity.uploader
|
||||
item.uploaderUrl = streamEntity.uploaderUrl
|
||||
item.thumbnailUrl = streamEntity.thumbnailUrl
|
||||
item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.extractor.stream.StreamType
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem
|
||||
import org.schabi.newpipe.util.image.ImageStrategy
|
||||
import java.io.Serializable
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@@ -67,7 +68,8 @@ data class StreamEntity(
|
||||
constructor(item: StreamInfoItem) : this(
|
||||
serviceId = item.serviceId, url = item.url, title = item.name,
|
||||
streamType = item.streamType, duration = item.duration, uploader = item.uploaderName,
|
||||
uploaderUrl = item.uploaderUrl, thumbnailUrl = item.thumbnailUrl, viewCount = item.viewCount,
|
||||
uploaderUrl = item.uploaderUrl,
|
||||
thumbnailUrl = ImageStrategy.imageListToDbUrl(item.thumbnails), viewCount = item.viewCount,
|
||||
textualUploadDate = item.textualUploadDate, uploadDate = item.uploadDate?.offsetDateTime(),
|
||||
isUploadDateApproximation = item.uploadDate?.isApproximation
|
||||
)
|
||||
@@ -76,7 +78,8 @@ data class StreamEntity(
|
||||
constructor(info: StreamInfo) : this(
|
||||
serviceId = info.serviceId, url = info.url, title = info.name,
|
||||
streamType = info.streamType, duration = info.duration, uploader = info.uploaderName,
|
||||
uploaderUrl = info.uploaderUrl, thumbnailUrl = info.thumbnailUrl, viewCount = info.viewCount,
|
||||
uploaderUrl = info.uploaderUrl,
|
||||
thumbnailUrl = ImageStrategy.imageListToDbUrl(info.thumbnails), viewCount = info.viewCount,
|
||||
textualUploadDate = info.textualUploadDate, uploadDate = info.uploadDate?.offsetDateTime(),
|
||||
isUploadDateApproximation = info.uploadDate?.isApproximation
|
||||
)
|
||||
@@ -85,7 +88,8 @@ data class StreamEntity(
|
||||
constructor(item: PlayQueueItem) : this(
|
||||
serviceId = item.serviceId, url = item.url, title = item.title,
|
||||
streamType = item.streamType, duration = item.duration, uploader = item.uploader,
|
||||
uploaderUrl = item.uploaderUrl, thumbnailUrl = item.thumbnailUrl
|
||||
uploaderUrl = item.uploaderUrl,
|
||||
thumbnailUrl = ImageStrategy.imageListToDbUrl(item.thumbnails)
|
||||
)
|
||||
|
||||
fun toStreamInfoItem(): StreamInfoItem {
|
||||
@@ -93,7 +97,7 @@ data class StreamEntity(
|
||||
item.duration = duration
|
||||
item.uploaderName = uploader
|
||||
item.uploaderUrl = uploaderUrl
|
||||
item.thumbnailUrl = thumbnailUrl
|
||||
item.thumbnails = ImageStrategy.dbUrlToImageList(thumbnailUrl)
|
||||
|
||||
if (viewCount != null) item.viewCount = viewCount as Long
|
||||
item.textualUploadDate = textualUploadDate
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.schabi.newpipe.database.subscription;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.room.ColumnInfo;
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.Ignore;
|
||||
@@ -10,6 +11,7 @@ import androidx.room.PrimaryKey;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
|
||||
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID;
|
||||
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE;
|
||||
@@ -57,8 +59,8 @@ public class SubscriptionEntity {
|
||||
final SubscriptionEntity result = new SubscriptionEntity();
|
||||
result.setServiceId(info.getServiceId());
|
||||
result.setUrl(info.getUrl());
|
||||
result.setData(info.getName(), info.getAvatarUrl(), info.getDescription(),
|
||||
info.getSubscriberCount());
|
||||
result.setData(info.getName(), ImageStrategy.imageListToDbUrl(info.getAvatars()),
|
||||
info.getDescription(), info.getSubscriberCount());
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -94,11 +96,12 @@ public class SubscriptionEntity {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getAvatarUrl() {
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
public void setAvatarUrl(final String avatarUrl) {
|
||||
public void setAvatarUrl(@Nullable final String avatarUrl) {
|
||||
this.avatarUrl = avatarUrl;
|
||||
}
|
||||
|
||||
@@ -138,7 +141,7 @@ public class SubscriptionEntity {
|
||||
@Ignore
|
||||
public ChannelInfoItem toChannelInfoItem() {
|
||||
final ChannelInfoItem item = new ChannelInfoItem(getServiceId(), getUrl(), getName());
|
||||
item.setThumbnailUrl(getAvatarUrl());
|
||||
item.setThumbnails(ImageStrategy.dbUrlToImageList(getAvatarUrl()));
|
||||
item.setSubscriberCount(getSubscriberCount());
|
||||
item.setDescription(getDescription());
|
||||
return item;
|
||||
|
||||
@@ -7,8 +7,6 @@ import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
import android.app.Activity;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.DialogInterface.OnDismissListener;
|
||||
import android.content.Intent;
|
||||
import android.content.ServiceConnection;
|
||||
import android.content.SharedPreferences;
|
||||
@@ -16,6 +14,7 @@ import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.os.IBinder;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
@@ -67,13 +66,14 @@ import org.schabi.newpipe.util.PermissionHelper;
|
||||
import org.schabi.newpipe.util.SecondaryStreamHelper;
|
||||
import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
|
||||
import org.schabi.newpipe.util.StreamItemAdapter;
|
||||
import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper;
|
||||
import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper;
|
||||
import org.schabi.newpipe.util.AudioTrackAdapter;
|
||||
import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
@@ -97,9 +97,9 @@ public class DownloadDialog extends DialogFragment
|
||||
@State
|
||||
StreamInfo currentInfo;
|
||||
@State
|
||||
StreamSizeWrapper<VideoStream> wrappedVideoStreams;
|
||||
StreamInfoWrapper<VideoStream> wrappedVideoStreams;
|
||||
@State
|
||||
StreamSizeWrapper<SubtitlesStream> wrappedSubtitleStreams;
|
||||
StreamInfoWrapper<SubtitlesStream> wrappedSubtitleStreams;
|
||||
@State
|
||||
AudioTracksWrapper wrappedAudioTracks;
|
||||
@State
|
||||
@@ -111,14 +111,11 @@ public class DownloadDialog extends DialogFragment
|
||||
@State
|
||||
int selectedSubtitleIndex = 0; // default to the first item
|
||||
|
||||
@Nullable
|
||||
private OnDismissListener onDismissListener = null;
|
||||
|
||||
private StoredDirectoryHelper mainStorageAudio = null;
|
||||
private StoredDirectoryHelper mainStorageVideo = null;
|
||||
private DownloadManager downloadManager = null;
|
||||
private ActionMenuItemView okButton = null;
|
||||
private Context context;
|
||||
private Context context = null;
|
||||
private boolean askForSavePath;
|
||||
|
||||
private AudioTrackAdapter audioTrackAdapter;
|
||||
@@ -146,7 +143,6 @@ public class DownloadDialog extends DialogFragment
|
||||
registerForActivityResult(
|
||||
new StartActivityForResult(), this::requestDownloadPickVideoFolderResult);
|
||||
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Instance creation
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@@ -187,20 +183,13 @@ public class DownloadDialog extends DialogFragment
|
||||
wrappedAudioTracks.size() > 1
|
||||
);
|
||||
|
||||
this.wrappedVideoStreams = new StreamSizeWrapper<>(videoStreams, context);
|
||||
this.wrappedSubtitleStreams = new StreamSizeWrapper<>(
|
||||
this.wrappedVideoStreams = new StreamInfoWrapper<>(videoStreams, context);
|
||||
this.wrappedSubtitleStreams = new StreamInfoWrapper<>(
|
||||
getStreamsOfSpecifiedDelivery(info.getSubtitles(), PROGRESSIVE_HTTP), context);
|
||||
|
||||
this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param onDismissListener the listener to call in {@link #onDismiss(DialogInterface)}
|
||||
*/
|
||||
public void setOnDismissListener(@Nullable final OnDismissListener onDismissListener) {
|
||||
this.onDismissListener = onDismissListener;
|
||||
}
|
||||
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Android lifecycle
|
||||
@@ -220,6 +209,8 @@ public class DownloadDialog extends DialogFragment
|
||||
return;
|
||||
}
|
||||
|
||||
// context will remain null if dismiss() was called above, allowing to check whether the
|
||||
// dialog is being dismissed in onViewCreated()
|
||||
context = getContext();
|
||||
|
||||
setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context));
|
||||
@@ -258,17 +249,17 @@ public class DownloadDialog extends DialogFragment
|
||||
* Update the displayed video streams based on the selected audio track.
|
||||
*/
|
||||
private void updateSecondaryStreams() {
|
||||
final StreamSizeWrapper<AudioStream> audioStreams = getWrappedAudioStreams();
|
||||
final StreamInfoWrapper<AudioStream> audioStreams = getWrappedAudioStreams();
|
||||
final var secondaryStreams = new SparseArrayCompat<SecondaryStreamHelper<AudioStream>>(4);
|
||||
final List<VideoStream> videoStreams = wrappedVideoStreams.getStreamsList();
|
||||
wrappedVideoStreams.resetSizes();
|
||||
wrappedVideoStreams.resetInfo();
|
||||
|
||||
for (int i = 0; i < videoStreams.size(); i++) {
|
||||
if (!videoStreams.get(i).isVideoOnly()) {
|
||||
continue;
|
||||
}
|
||||
final AudioStream audioStream = SecondaryStreamHelper
|
||||
.getAudioStreamFor(audioStreams.getStreamsList(), videoStreams.get(i));
|
||||
final AudioStream audioStream = SecondaryStreamHelper.getAudioStreamFor(
|
||||
context, audioStreams.getStreamsList(), videoStreams.get(i));
|
||||
|
||||
if (audioStream != null) {
|
||||
secondaryStreams.append(i, new SecondaryStreamHelper<>(audioStreams, audioStream));
|
||||
@@ -304,6 +295,9 @@ public class DownloadDialog extends DialogFragment
|
||||
@Nullable final Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
dialogBinding = DownloadDialogBinding.bind(view);
|
||||
if (context == null) {
|
||||
return; // the dialog is being dismissed, see the call to dismiss() in onCreate()
|
||||
}
|
||||
|
||||
dialogBinding.fileName.setText(FilenameUtils.createFilename(getContext(),
|
||||
currentInfo.getName()));
|
||||
@@ -313,6 +307,7 @@ public class DownloadDialog extends DialogFragment
|
||||
selectedSubtitleIndex = getSubtitleIndexBy(subtitleStreamsAdapter.getAll());
|
||||
|
||||
dialogBinding.qualitySpinner.setOnItemSelectedListener(this);
|
||||
dialogBinding.audioStreamSpinner.setOnItemSelectedListener(this);
|
||||
dialogBinding.audioTrackSpinner.setOnItemSelectedListener(this);
|
||||
dialogBinding.videoAudioGroup.setOnCheckedChangeListener(this);
|
||||
|
||||
@@ -362,14 +357,6 @@ public class DownloadDialog extends DialogFragment
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDismiss(@NonNull final DialogInterface dialog) {
|
||||
super.onDismiss(dialog);
|
||||
if (onDismissListener != null) {
|
||||
onDismissListener.onDismiss(dialog);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
@@ -395,7 +382,7 @@ public class DownloadDialog extends DialogFragment
|
||||
|
||||
private void fetchStreamsSize() {
|
||||
disposables.clear();
|
||||
disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedVideoStreams)
|
||||
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedVideoStreams)
|
||||
.subscribe(result -> {
|
||||
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
|
||||
== R.id.video_button) {
|
||||
@@ -405,7 +392,7 @@ public class DownloadDialog extends DialogFragment
|
||||
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
|
||||
"Downloading video stream size",
|
||||
currentInfo.getServiceId()))));
|
||||
disposables.add(StreamSizeWrapper.fetchSizeForWrapper(getWrappedAudioStreams())
|
||||
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(getWrappedAudioStreams())
|
||||
.subscribe(result -> {
|
||||
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
|
||||
== R.id.audio_button) {
|
||||
@@ -415,7 +402,7 @@ public class DownloadDialog extends DialogFragment
|
||||
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
|
||||
"Downloading audio stream size",
|
||||
currentInfo.getServiceId()))));
|
||||
disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedSubtitleStreams)
|
||||
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedSubtitleStreams)
|
||||
.subscribe(result -> {
|
||||
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
|
||||
== R.id.subtitle_button) {
|
||||
@@ -563,7 +550,6 @@ public class DownloadDialog extends DialogFragment
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Listeners
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@@ -723,9 +709,9 @@ public class DownloadDialog extends DialogFragment
|
||||
dialogBinding.subtitleButton.setEnabled(enabled);
|
||||
}
|
||||
|
||||
private StreamSizeWrapper<AudioStream> getWrappedAudioStreams() {
|
||||
private StreamInfoWrapper<AudioStream> getWrappedAudioStreams() {
|
||||
if (selectedAudioTrackIndex < 0 || selectedAudioTrackIndex > wrappedAudioTracks.size()) {
|
||||
return StreamSizeWrapper.empty();
|
||||
return StreamInfoWrapper.empty();
|
||||
}
|
||||
return wrappedAudioTracks.getTracksList().get(selectedAudioTrackIndex);
|
||||
}
|
||||
@@ -765,7 +751,7 @@ public class DownloadDialog extends DialogFragment
|
||||
}
|
||||
|
||||
private void showFailedDialog(@StringRes final int msg) {
|
||||
assureCorrectAppLanguage(getContext());
|
||||
assureCorrectAppLanguage(requireContext());
|
||||
new AlertDialog.Builder(context)
|
||||
.setTitle(R.string.general_error)
|
||||
.setMessage(msg)
|
||||
@@ -782,6 +768,7 @@ public class DownloadDialog extends DialogFragment
|
||||
final StoredDirectoryHelper mainStorage;
|
||||
final MediaFormat format;
|
||||
final String selectedMediaType;
|
||||
final long size;
|
||||
|
||||
// first, build the filename and get the output folder (if possible)
|
||||
// later, run a very very very large file checking logic
|
||||
@@ -793,35 +780,38 @@ public class DownloadDialog extends DialogFragment
|
||||
selectedMediaType = getString(R.string.last_download_type_audio_key);
|
||||
mainStorage = mainStorageAudio;
|
||||
format = audioStreamsAdapter.getItem(selectedAudioIndex).getFormat();
|
||||
size = getWrappedAudioStreams().getSizeInBytes(selectedAudioIndex);
|
||||
if (format == MediaFormat.WEBMA_OPUS) {
|
||||
mimeTmp = "audio/ogg";
|
||||
filenameTmp += "opus";
|
||||
} else if (format != null) {
|
||||
mimeTmp = format.mimeType;
|
||||
filenameTmp += format.suffix;
|
||||
filenameTmp += format.getSuffix();
|
||||
}
|
||||
break;
|
||||
case R.id.video_button:
|
||||
selectedMediaType = getString(R.string.last_download_type_video_key);
|
||||
mainStorage = mainStorageVideo;
|
||||
format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat();
|
||||
size = wrappedVideoStreams.getSizeInBytes(selectedVideoIndex);
|
||||
if (format != null) {
|
||||
mimeTmp = format.mimeType;
|
||||
filenameTmp += format.suffix;
|
||||
filenameTmp += format.getSuffix();
|
||||
}
|
||||
break;
|
||||
case R.id.subtitle_button:
|
||||
selectedMediaType = getString(R.string.last_download_type_subtitle_key);
|
||||
mainStorage = mainStorageVideo; // subtitle & video files go together
|
||||
format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat();
|
||||
size = wrappedSubtitleStreams.getSizeInBytes(selectedSubtitleIndex);
|
||||
if (format != null) {
|
||||
mimeTmp = format.mimeType;
|
||||
}
|
||||
|
||||
if (format == MediaFormat.TTML) {
|
||||
filenameTmp += MediaFormat.SRT.suffix;
|
||||
filenameTmp += MediaFormat.SRT.getSuffix();
|
||||
} else if (format != null) {
|
||||
filenameTmp += format.suffix;
|
||||
filenameTmp += format.getSuffix();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
@@ -869,6 +859,21 @@ public class DownloadDialog extends DialogFragment
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for free storage space
|
||||
final long freeSpace = mainStorage.getFreeStorageSpace();
|
||||
if (freeSpace <= size) {
|
||||
Toast.makeText(context, getString(R.
|
||||
string.error_insufficient_storage), Toast.LENGTH_LONG).show();
|
||||
// move the user to storage setting tab
|
||||
final Intent storageSettingsIntent = new Intent(Settings.
|
||||
ACTION_INTERNAL_STORAGE_SETTINGS);
|
||||
if (storageSettingsIntent.resolveActivity(context.getPackageManager())
|
||||
!= null) {
|
||||
startActivity(storageSettingsIntent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// check for existing file with the same name
|
||||
checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp,
|
||||
mimeTmp);
|
||||
@@ -1051,7 +1056,7 @@ public class DownloadDialog extends DialogFragment
|
||||
final char kind;
|
||||
int threads = dialogBinding.threads.getProgress() + 1;
|
||||
final String[] urls;
|
||||
final MissionRecoveryInfo[] recoveryInfo;
|
||||
final List<MissionRecoveryInfo> recoveryInfo;
|
||||
String psName = null;
|
||||
String[] psArgs = null;
|
||||
long nearLength = 0;
|
||||
@@ -1116,9 +1121,7 @@ public class DownloadDialog extends DialogFragment
|
||||
urls = new String[] {
|
||||
selectedStream.getContent()
|
||||
};
|
||||
recoveryInfo = new MissionRecoveryInfo[] {
|
||||
new MissionRecoveryInfo(selectedStream)
|
||||
};
|
||||
recoveryInfo = List.of(new MissionRecoveryInfo(selectedStream));
|
||||
} else {
|
||||
if (secondaryStream.getDeliveryMethod() != PROGRESSIVE_HTTP) {
|
||||
throw new IllegalArgumentException("Unsupported stream delivery format"
|
||||
@@ -1128,12 +1131,14 @@ public class DownloadDialog extends DialogFragment
|
||||
urls = new String[] {
|
||||
selectedStream.getContent(), secondaryStream.getContent()
|
||||
};
|
||||
recoveryInfo = new MissionRecoveryInfo[] {new MissionRecoveryInfo(selectedStream),
|
||||
new MissionRecoveryInfo(secondaryStream)};
|
||||
recoveryInfo = List.of(
|
||||
new MissionRecoveryInfo(selectedStream),
|
||||
new MissionRecoveryInfo(secondaryStream)
|
||||
);
|
||||
}
|
||||
|
||||
DownloadManagerService.startMission(context, urls, storage, kind, threads,
|
||||
currentInfo.getUrl(), psName, psArgs, nearLength, recoveryInfo);
|
||||
currentInfo.getUrl(), psName, psArgs, nearLength, new ArrayList<>(recoveryInfo));
|
||||
|
||||
Toast.makeText(context, getString(R.string.download_has_started),
|
||||
Toast.LENGTH_SHORT).show();
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.schabi.newpipe.download;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
|
||||
import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.DownloadLoadingDialogBinding;
|
||||
|
||||
/**
|
||||
* This class contains a dialog which shows a loading indicator and has a customizable title.
|
||||
*/
|
||||
public class LoadingDialog extends DialogFragment {
|
||||
private static final String TAG = "LoadingDialog";
|
||||
private static final boolean DEBUG = MainActivity.DEBUG;
|
||||
private DownloadLoadingDialogBinding dialogLoadingBinding;
|
||||
private final @StringRes int title;
|
||||
|
||||
/**
|
||||
* Create a new LoadingDialog.
|
||||
*
|
||||
* <p>
|
||||
* The dialog contains a loading indicator and has a customizable title.
|
||||
* <br/>
|
||||
* Use {@code show()} to display the dialog to the user.
|
||||
* </p>
|
||||
*
|
||||
* @param title an informative title shown in the dialog's toolbar
|
||||
*/
|
||||
public LoadingDialog(final @StringRes int title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable final Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreate() called with: "
|
||||
+ "savedInstanceState = [" + savedInstanceState + "]");
|
||||
}
|
||||
this.setCancelable(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(
|
||||
@NonNull final LayoutInflater inflater,
|
||||
final ViewGroup container,
|
||||
final Bundle savedInstanceState) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreateView() called with: "
|
||||
+ "inflater = [" + inflater + "], container = [" + container + "], "
|
||||
+ "savedInstanceState = [" + savedInstanceState + "]");
|
||||
}
|
||||
return inflater.inflate(R.layout.download_loading_dialog, container);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
dialogLoadingBinding = DownloadLoadingDialogBinding.bind(view);
|
||||
initToolbar(dialogLoadingBinding.toolbarLayout.toolbar);
|
||||
}
|
||||
|
||||
private void initToolbar(final Toolbar toolbar) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]");
|
||||
}
|
||||
toolbar.setTitle(requireContext().getString(title));
|
||||
toolbar.setNavigationOnClickListener(v -> dismiss());
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
dialogLoadingBinding = null;
|
||||
super.onDestroyView();
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.content.IntentCompat;
|
||||
|
||||
import com.grack.nanojson.JsonWriter;
|
||||
|
||||
@@ -105,7 +106,7 @@ public class ErrorActivity extends AppCompatActivity {
|
||||
actionBar.setDisplayShowTitleEnabled(true);
|
||||
}
|
||||
|
||||
errorInfo = intent.getParcelableExtra(ERROR_INFO);
|
||||
errorInfo = IntentCompat.getParcelableExtra(intent, ERROR_INFO, ErrorInfo.class);
|
||||
|
||||
// important add guru meditation
|
||||
addGuruMeditation();
|
||||
|
||||
@@ -11,7 +11,6 @@ import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException
|
||||
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor.DeobfuscateException
|
||||
import org.schabi.newpipe.ktx.isNetworkRelated
|
||||
import org.schabi.newpipe.util.ServiceHelper
|
||||
|
||||
@@ -96,7 +95,6 @@ class ErrorInfo(
|
||||
throwable is ContentNotAvailableException -> R.string.content_not_available
|
||||
throwable != null && throwable.isNetworkRelated -> R.string.network_error
|
||||
throwable is ContentNotSupportedException -> R.string.content_not_supported
|
||||
throwable is DeobfuscateException -> R.string.youtube_signature_deobfuscation_error
|
||||
throwable is ExtractionException -> R.string.parsing_error
|
||||
throwable is ExoPlaybackException -> {
|
||||
when (throwable.type) {
|
||||
|
||||
@@ -6,6 +6,7 @@ package org.schabi.newpipe.error;
|
||||
public enum UserAction {
|
||||
USER_REPORT("user report"),
|
||||
UI_ERROR("ui error"),
|
||||
DATABASE_IMPORT_EXPORT("database import or export"),
|
||||
SUBSCRIPTION_CHANGE("subscription change"),
|
||||
SUBSCRIPTION_UPDATE("subscription update"),
|
||||
SUBSCRIPTION_GET("get subscription"),
|
||||
@@ -19,6 +20,7 @@ public enum UserAction {
|
||||
REQUESTED_PLAYLIST("requested playlist"),
|
||||
REQUESTED_KIOSK("requested kiosk"),
|
||||
REQUESTED_COMMENTS("requested comments"),
|
||||
REQUESTED_COMMENT_REPLIES("requested comment replies"),
|
||||
REQUESTED_FEED("requested feed"),
|
||||
REQUESTED_BOOKMARK("bookmark"),
|
||||
DELETE_FROM_HISTORY("delete from history"),
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package org.schabi.newpipe.fragments;
|
||||
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import org.schabi.newpipe.BaseFragment;
|
||||
@@ -20,15 +24,15 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import icepick.State;
|
||||
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
|
||||
public abstract class BaseStateFragment<I> extends BaseFragment implements ViewContract<I> {
|
||||
@State
|
||||
protected AtomicBoolean wasLoading = new AtomicBoolean();
|
||||
protected AtomicBoolean isLoading = new AtomicBoolean();
|
||||
|
||||
@Nullable
|
||||
private View emptyStateView;
|
||||
protected View emptyStateView;
|
||||
@Nullable
|
||||
protected TextView emptyStateMessageView;
|
||||
@Nullable
|
||||
private ProgressBar loadingProgressBar;
|
||||
|
||||
@@ -65,6 +69,7 @@ public abstract class BaseStateFragment<I> extends BaseFragment implements ViewC
|
||||
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
||||
super.initViews(rootView, savedInstanceState);
|
||||
emptyStateView = rootView.findViewById(R.id.empty_state_view);
|
||||
emptyStateMessageView = rootView.findViewById(R.id.empty_state_message);
|
||||
loadingProgressBar = rootView.findViewById(R.id.loading_progress_bar);
|
||||
errorPanelHelper = new ErrorPanelHelper(this, rootView, this::onRetryButtonClicked);
|
||||
}
|
||||
@@ -75,6 +80,8 @@ public abstract class BaseStateFragment<I> extends BaseFragment implements ViewC
|
||||
if (errorPanelHelper != null) {
|
||||
errorPanelHelper.dispose();
|
||||
}
|
||||
emptyStateView = null;
|
||||
emptyStateMessageView = null;
|
||||
}
|
||||
|
||||
protected void onRetryButtonClicked() {
|
||||
@@ -189,6 +196,12 @@ public abstract class BaseStateFragment<I> extends BaseFragment implements ViewC
|
||||
errorPanelHelper.showTextError(errorString);
|
||||
}
|
||||
|
||||
protected void setEmptyStateMessage(@StringRes final int text) {
|
||||
if (emptyStateMessageView != null) {
|
||||
emptyStateMessageView.setText(text);
|
||||
}
|
||||
}
|
||||
|
||||
public final void hideErrorPanel() {
|
||||
errorPanelHelper.hide();
|
||||
lastPanelError = null;
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
package org.schabi.newpipe.fragments;
|
||||
|
||||
import static android.widget.RelativeLayout.ABOVE;
|
||||
import static android.widget.RelativeLayout.ALIGN_PARENT_BOTTOM;
|
||||
import static android.widget.RelativeLayout.ALIGN_PARENT_TOP;
|
||||
import static android.widget.RelativeLayout.BELOW;
|
||||
import static com.google.android.material.tabs.TabLayout.INDICATOR_GRAVITY_BOTTOM;
|
||||
import static com.google.android.material.tabs.TabLayout.INDICATOR_GRAVITY_TOP;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
@@ -9,7 +19,9 @@ import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.RelativeLayout;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
@@ -17,6 +29,7 @@ import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.fragment.app.FragmentStatePagerAdapterMenuWorkaround;
|
||||
import androidx.preference.PreferenceManager;
|
||||
import androidx.viewpager.widget.ViewPager;
|
||||
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
|
||||
@@ -25,10 +38,13 @@ import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.FragmentMainBinding;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
|
||||
import org.schabi.newpipe.settings.tabs.Tab;
|
||||
import org.schabi.newpipe.settings.tabs.TabsManager;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.views.ScrollableTabLayout;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -42,8 +58,11 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
||||
|
||||
private boolean hasTabsChanged = false;
|
||||
|
||||
private boolean previousYoutubeRestrictedModeEnabled;
|
||||
private SharedPreferences prefs;
|
||||
private boolean youtubeRestrictedModeEnabled;
|
||||
private String youtubeRestrictedModeEnabledKey;
|
||||
private boolean mainTabsPositionBottom;
|
||||
private String mainTabsPositionKey;
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Fragment's LifeCycle
|
||||
@@ -66,10 +85,11 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
||||
}
|
||||
});
|
||||
|
||||
prefs = PreferenceManager.getDefaultSharedPreferences(requireContext());
|
||||
youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled);
|
||||
previousYoutubeRestrictedModeEnabled =
|
||||
PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
.getBoolean(youtubeRestrictedModeEnabledKey, false);
|
||||
youtubeRestrictedModeEnabled = prefs.getBoolean(youtubeRestrictedModeEnabledKey, false);
|
||||
mainTabsPositionKey = getString(R.string.main_tabs_position_key);
|
||||
mainTabsPositionBottom = prefs.getBoolean(mainTabsPositionKey, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -87,25 +107,27 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
||||
|
||||
binding.mainTabLayout.setupWithViewPager(binding.pager);
|
||||
binding.mainTabLayout.addOnTabSelectedListener(this);
|
||||
binding.mainTabLayout.setTabRippleColor(binding.mainTabLayout.getTabRippleColor()
|
||||
.withAlpha(32));
|
||||
|
||||
setupTabs();
|
||||
updateTabLayoutPosition();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
|
||||
final boolean youtubeRestrictedModeEnabled =
|
||||
PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||
.getBoolean(youtubeRestrictedModeEnabledKey, false);
|
||||
if (previousYoutubeRestrictedModeEnabled != youtubeRestrictedModeEnabled) {
|
||||
previousYoutubeRestrictedModeEnabled = youtubeRestrictedModeEnabled;
|
||||
setupTabs();
|
||||
} else if (hasTabsChanged) {
|
||||
final boolean newYoutubeRestrictedModeEnabled =
|
||||
prefs.getBoolean(youtubeRestrictedModeEnabledKey, false);
|
||||
if (youtubeRestrictedModeEnabled != newYoutubeRestrictedModeEnabled || hasTabsChanged) {
|
||||
youtubeRestrictedModeEnabled = newYoutubeRestrictedModeEnabled;
|
||||
setupTabs();
|
||||
}
|
||||
|
||||
final boolean newMainTabsPosition = prefs.getBoolean(mainTabsPositionKey, false);
|
||||
if (mainTabsPositionBottom != newMainTabsPosition) {
|
||||
mainTabsPositionBottom = newMainTabsPosition;
|
||||
updateTabLayoutPosition();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -118,6 +140,12 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
binding = null;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Menu
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@@ -166,7 +194,6 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
||||
}
|
||||
|
||||
binding.pager.setAdapter(null);
|
||||
binding.pager.setOffscreenPageLimit(tabsList.size());
|
||||
binding.pager.setAdapter(pagerAdapter);
|
||||
|
||||
updateTabsIconAndDescription();
|
||||
@@ -190,6 +217,44 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
||||
setTitle(tabsList.get(tabPosition).getTabName(requireContext()));
|
||||
}
|
||||
|
||||
public void commitPlaylistTabs() {
|
||||
pagerAdapter.getLocalPlaylistFragments()
|
||||
.stream()
|
||||
.forEach(LocalPlaylistFragment::saveImmediate);
|
||||
}
|
||||
|
||||
private void updateTabLayoutPosition() {
|
||||
final ScrollableTabLayout tabLayout = binding.mainTabLayout;
|
||||
final ViewPager viewPager = binding.pager;
|
||||
final boolean bottom = mainTabsPositionBottom;
|
||||
|
||||
// change layout params to make the tab layout appear either at the top or at the bottom
|
||||
final var tabParams = (RelativeLayout.LayoutParams) tabLayout.getLayoutParams();
|
||||
final var pagerParams = (RelativeLayout.LayoutParams) viewPager.getLayoutParams();
|
||||
|
||||
tabParams.removeRule(bottom ? ALIGN_PARENT_TOP : ALIGN_PARENT_BOTTOM);
|
||||
tabParams.addRule(bottom ? ALIGN_PARENT_BOTTOM : ALIGN_PARENT_TOP);
|
||||
pagerParams.removeRule(bottom ? BELOW : ABOVE);
|
||||
pagerParams.addRule(bottom ? ABOVE : BELOW, R.id.main_tab_layout);
|
||||
tabLayout.setSelectedTabIndicatorGravity(
|
||||
bottom ? INDICATOR_GRAVITY_TOP : INDICATOR_GRAVITY_BOTTOM);
|
||||
|
||||
tabLayout.setLayoutParams(tabParams);
|
||||
viewPager.setLayoutParams(pagerParams);
|
||||
|
||||
// change the background and icon color of the tab layout:
|
||||
// service-colored at the top, app-background-colored at the bottom
|
||||
tabLayout.setBackgroundColor(ThemeHelper.resolveColorFromAttr(requireContext(),
|
||||
bottom ? R.attr.colorSecondary : R.attr.colorPrimary));
|
||||
|
||||
@ColorInt final int iconColor = bottom
|
||||
? ThemeHelper.resolveColorFromAttr(requireContext(), R.attr.colorAccent)
|
||||
: Color.WHITE;
|
||||
tabLayout.setTabRippleColor(ColorStateList.valueOf(iconColor).withAlpha(32));
|
||||
tabLayout.setTabIconTint(ColorStateList.valueOf(iconColor));
|
||||
tabLayout.setSelectedTabIndicatorColor(iconColor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTabSelected(final TabLayout.Tab selectedTab) {
|
||||
if (DEBUG) {
|
||||
@@ -209,10 +274,18 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
||||
updateTitleForTab(tab.getPosition());
|
||||
}
|
||||
|
||||
private static final class SelectedTabsPagerAdapter
|
||||
public static final class SelectedTabsPagerAdapter
|
||||
extends FragmentStatePagerAdapterMenuWorkaround {
|
||||
private final Context context;
|
||||
private final List<Tab> internalTabsList;
|
||||
/**
|
||||
* Keep reference to LocalPlaylistFragments, because their data can be modified by the user
|
||||
* during runtime and changes are not committed immediately. However, in some cases,
|
||||
* the changes need to be committed immediately by calling
|
||||
* {@link LocalPlaylistFragment#saveImmediate()}.
|
||||
* The fragments are removed when {@link LocalPlaylistFragment#onDestroy()} is called.
|
||||
*/
|
||||
private final List<LocalPlaylistFragment> localPlaylistFragments = new ArrayList<>();
|
||||
|
||||
private SelectedTabsPagerAdapter(final Context context,
|
||||
final FragmentManager fragmentManager,
|
||||
@@ -239,9 +312,17 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
||||
((BaseFragment) fragment).useAsFrontPage(true);
|
||||
}
|
||||
|
||||
if (fragment instanceof LocalPlaylistFragment) {
|
||||
localPlaylistFragments.add((LocalPlaylistFragment) fragment);
|
||||
}
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
public List<LocalPlaylistFragment> getLocalPlaylistFragments() {
|
||||
return localPlaylistFragments;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemPosition(@NonNull final Object object) {
|
||||
// Causes adapter to reload all Fragments when
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package org.schabi.newpipe.fragments.detail;
|
||||
|
||||
import static android.text.TextUtils.isEmpty;
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
||||
import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD;
|
||||
|
||||
import android.graphics.Typeface;
|
||||
import android.os.Bundle;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.text.style.ClickableSpan;
|
||||
import android.text.style.StyleSpan;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.widget.TooltipCompat;
|
||||
import androidx.core.text.HtmlCompat;
|
||||
|
||||
import com.google.android.material.chip.Chip;
|
||||
|
||||
import org.schabi.newpipe.BaseFragment;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.FragmentDescriptionBinding;
|
||||
import org.schabi.newpipe.databinding.ItemMetadataBinding;
|
||||
import org.schabi.newpipe.databinding.ItemMetadataTagsBinding;
|
||||
import org.schabi.newpipe.extractor.Image;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
import org.schabi.newpipe.extractor.stream.Description;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
import org.schabi.newpipe.util.text.TextLinkifier;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
|
||||
public abstract class BaseDescriptionFragment extends BaseFragment {
|
||||
private final CompositeDisposable descriptionDisposables = new CompositeDisposable();
|
||||
protected FragmentDescriptionBinding binding;
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull final LayoutInflater inflater,
|
||||
@Nullable final ViewGroup container,
|
||||
@Nullable final Bundle savedInstanceState) {
|
||||
binding = FragmentDescriptionBinding.inflate(inflater, container, false);
|
||||
setupDescription();
|
||||
setupMetadata(inflater, binding.detailMetadataLayout);
|
||||
addTagsMetadataItem(inflater, binding.detailMetadataLayout);
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
descriptionDisposables.clear();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the description to display.
|
||||
* @return description object, if available
|
||||
*/
|
||||
@Nullable
|
||||
protected abstract Description getDescription();
|
||||
|
||||
/**
|
||||
* Get the streaming service. Used for generating description links.
|
||||
* @return streaming service
|
||||
*/
|
||||
@NonNull
|
||||
protected abstract StreamingService getService();
|
||||
|
||||
/**
|
||||
* Get the streaming service ID. Used for tag links.
|
||||
* @return service ID
|
||||
*/
|
||||
protected abstract int getServiceId();
|
||||
|
||||
/**
|
||||
* Get the URL of the described video or audio, used to generate description links.
|
||||
* @return stream URL
|
||||
*/
|
||||
@Nullable
|
||||
protected abstract String getStreamUrl();
|
||||
|
||||
/**
|
||||
* Get the list of tags to display below the description.
|
||||
* @return tag list
|
||||
*/
|
||||
@NonNull
|
||||
public abstract List<String> getTags();
|
||||
|
||||
/**
|
||||
* Add additional metadata to display.
|
||||
* @param inflater LayoutInflater
|
||||
* @param layout detailMetadataLayout
|
||||
*/
|
||||
protected abstract void setupMetadata(LayoutInflater inflater, LinearLayout layout);
|
||||
|
||||
private void setupDescription() {
|
||||
final Description description = getDescription();
|
||||
if (description == null || isEmpty(description.getContent())
|
||||
|| description == Description.EMPTY_DESCRIPTION) {
|
||||
binding.detailDescriptionView.setVisibility(View.GONE);
|
||||
binding.detailSelectDescriptionButton.setVisibility(View.GONE);
|
||||
return;
|
||||
}
|
||||
|
||||
// start with disabled state. This also loads description content (!)
|
||||
disableDescriptionSelection();
|
||||
|
||||
binding.detailSelectDescriptionButton.setOnClickListener(v -> {
|
||||
if (binding.detailDescriptionNoteView.getVisibility() == View.VISIBLE) {
|
||||
disableDescriptionSelection();
|
||||
} else {
|
||||
// enable selection only when button is clicked to prevent flickering
|
||||
enableDescriptionSelection();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void enableDescriptionSelection() {
|
||||
binding.detailDescriptionNoteView.setVisibility(View.VISIBLE);
|
||||
binding.detailDescriptionView.setTextIsSelectable(true);
|
||||
|
||||
final String buttonLabel = getString(R.string.description_select_disable);
|
||||
binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
|
||||
TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
|
||||
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_close);
|
||||
}
|
||||
|
||||
private void disableDescriptionSelection() {
|
||||
// show description content again, otherwise some links are not clickable
|
||||
final Description description = getDescription();
|
||||
if (description != null) {
|
||||
TextLinkifier.fromDescription(binding.detailDescriptionView,
|
||||
description, HtmlCompat.FROM_HTML_MODE_LEGACY,
|
||||
getService(), getStreamUrl(),
|
||||
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
|
||||
}
|
||||
|
||||
binding.detailDescriptionNoteView.setVisibility(View.GONE);
|
||||
binding.detailDescriptionView.setTextIsSelectable(false);
|
||||
|
||||
final String buttonLabel = getString(R.string.description_select_enable);
|
||||
binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
|
||||
TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
|
||||
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all);
|
||||
}
|
||||
|
||||
protected void addMetadataItem(final LayoutInflater inflater,
|
||||
final LinearLayout layout,
|
||||
final boolean linkifyContent,
|
||||
@StringRes final int type,
|
||||
@NonNull final String content) {
|
||||
if (isBlank(content)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final ItemMetadataBinding itemBinding =
|
||||
ItemMetadataBinding.inflate(inflater, layout, false);
|
||||
|
||||
itemBinding.metadataTypeView.setText(type);
|
||||
itemBinding.metadataTypeView.setOnLongClickListener(v -> {
|
||||
ShareUtils.copyToClipboard(requireContext(), content);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (linkifyContent) {
|
||||
TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null,
|
||||
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
|
||||
} else {
|
||||
itemBinding.metadataContentView.setText(content);
|
||||
}
|
||||
|
||||
itemBinding.metadataContentView.setClickable(true);
|
||||
|
||||
layout.addView(itemBinding.getRoot());
|
||||
}
|
||||
|
||||
private String imageSizeToText(final int heightOrWidth) {
|
||||
if (heightOrWidth < 0) {
|
||||
return getString(R.string.question_mark);
|
||||
} else {
|
||||
return String.valueOf(heightOrWidth);
|
||||
}
|
||||
}
|
||||
|
||||
protected void addImagesMetadataItem(final LayoutInflater inflater,
|
||||
final LinearLayout layout,
|
||||
@StringRes final int type,
|
||||
final List<Image> images) {
|
||||
final String preferredImageUrl = ImageStrategy.choosePreferredImage(images);
|
||||
if (preferredImageUrl == null) {
|
||||
return; // null will be returned in case there is no image
|
||||
}
|
||||
|
||||
final ItemMetadataBinding itemBinding =
|
||||
ItemMetadataBinding.inflate(inflater, layout, false);
|
||||
itemBinding.metadataTypeView.setText(type);
|
||||
|
||||
final SpannableStringBuilder urls = new SpannableStringBuilder();
|
||||
for (final Image image : images) {
|
||||
if (urls.length() != 0) {
|
||||
urls.append(", ");
|
||||
}
|
||||
final int entryBegin = urls.length();
|
||||
|
||||
if (image.getHeight() != Image.HEIGHT_UNKNOWN
|
||||
|| image.getWidth() != Image.WIDTH_UNKNOWN
|
||||
// if even the resolution level is unknown, ?x? will be shown
|
||||
|| image.getEstimatedResolutionLevel() == Image.ResolutionLevel.UNKNOWN) {
|
||||
urls.append(imageSizeToText(image.getHeight()));
|
||||
urls.append('x');
|
||||
urls.append(imageSizeToText(image.getWidth()));
|
||||
} else {
|
||||
switch (image.getEstimatedResolutionLevel()) {
|
||||
case LOW -> urls.append(getString(R.string.image_quality_low));
|
||||
case MEDIUM -> urls.append(getString(R.string.image_quality_medium));
|
||||
case HIGH -> urls.append(getString(R.string.image_quality_high));
|
||||
default -> {
|
||||
// unreachable, Image.ResolutionLevel.UNKNOWN is already filtered out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
urls.setSpan(new ClickableSpan() {
|
||||
@Override
|
||||
public void onClick(@NonNull final View widget) {
|
||||
ShareUtils.openUrlInBrowser(requireContext(), image.getUrl());
|
||||
}
|
||||
}, entryBegin, urls.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
if (preferredImageUrl.equals(image.getUrl())) {
|
||||
urls.setSpan(new StyleSpan(Typeface.BOLD), entryBegin, urls.length(),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
itemBinding.metadataContentView.setText(urls);
|
||||
itemBinding.metadataContentView.setMovementMethod(LinkMovementMethod.getInstance());
|
||||
layout.addView(itemBinding.getRoot());
|
||||
}
|
||||
|
||||
private void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) {
|
||||
final List<String> tags = getTags();
|
||||
|
||||
if (!tags.isEmpty()) {
|
||||
final var itemBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false);
|
||||
|
||||
tags.stream().sorted(String.CASE_INSENSITIVE_ORDER).forEach(tag -> {
|
||||
final Chip chip = (Chip) inflater.inflate(R.layout.chip,
|
||||
itemBinding.metadataTagsChips, false);
|
||||
chip.setText(tag);
|
||||
chip.setOnClickListener(this::onTagClick);
|
||||
chip.setOnLongClickListener(this::onTagLongClick);
|
||||
itemBinding.metadataTagsChips.addView(chip);
|
||||
});
|
||||
|
||||
layout.addView(itemBinding.getRoot());
|
||||
}
|
||||
}
|
||||
|
||||
private void onTagClick(final View chip) {
|
||||
if (getParentFragment() != null) {
|
||||
NavigationHelper.openSearchFragment(getParentFragment().getParentFragmentManager(),
|
||||
getServiceId(), ((Chip) chip).getText().toString());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean onTagLongClick(final View chip) {
|
||||
ShareUtils.copyToClipboard(requireContext(), ((Chip) chip).getText().toString());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,134 +1,83 @@
|
||||
package org.schabi.newpipe.fragments.detail;
|
||||
|
||||
import static android.text.TextUtils.isEmpty;
|
||||
import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT;
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
||||
import static org.schabi.newpipe.util.Localization.getAppLocale;
|
||||
import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.widget.TooltipCompat;
|
||||
import androidx.core.text.HtmlCompat;
|
||||
|
||||
import com.google.android.material.chip.Chip;
|
||||
|
||||
import org.schabi.newpipe.BaseFragment;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.FragmentDescriptionBinding;
|
||||
import org.schabi.newpipe.databinding.ItemMetadataBinding;
|
||||
import org.schabi.newpipe.databinding.ItemMetadataTagsBinding;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
import org.schabi.newpipe.extractor.stream.Description;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
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.text.TextLinkifier;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import icepick.State;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
|
||||
public class DescriptionFragment extends BaseFragment {
|
||||
public class DescriptionFragment extends BaseDescriptionFragment {
|
||||
|
||||
@State
|
||||
StreamInfo streamInfo = null;
|
||||
final CompositeDisposable descriptionDisposables = new CompositeDisposable();
|
||||
FragmentDescriptionBinding binding;
|
||||
|
||||
public DescriptionFragment() {
|
||||
}
|
||||
StreamInfo streamInfo;
|
||||
|
||||
public DescriptionFragment(final StreamInfo streamInfo) {
|
||||
this.streamInfo = streamInfo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull final LayoutInflater inflater,
|
||||
@Nullable final ViewGroup container,
|
||||
@Nullable final Bundle savedInstanceState) {
|
||||
binding = FragmentDescriptionBinding.inflate(inflater, container, false);
|
||||
if (streamInfo != null) {
|
||||
setupUploadDate();
|
||||
setupDescription();
|
||||
setupMetadata(inflater, binding.detailMetadataLayout);
|
||||
}
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
descriptionDisposables.clear();
|
||||
super.onDestroy();
|
||||
public DescriptionFragment() {
|
||||
// keep empty constructor for IcePick when resuming fragment from memory
|
||||
}
|
||||
|
||||
|
||||
private void setupUploadDate() {
|
||||
if (streamInfo.getUploadDate() != null) {
|
||||
@Nullable
|
||||
@Override
|
||||
protected Description getDescription() {
|
||||
return streamInfo.getDescription();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
protected StreamingService getService() {
|
||||
return streamInfo.getService();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getServiceId() {
|
||||
return streamInfo.getServiceId();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
protected String getStreamUrl() {
|
||||
return streamInfo.getUrl();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public List<String> getTags() {
|
||||
return streamInfo.getTags();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setupMetadata(final LayoutInflater inflater,
|
||||
final LinearLayout layout) {
|
||||
if (streamInfo != null && streamInfo.getUploadDate() != null) {
|
||||
binding.detailUploadDateView.setText(Localization
|
||||
.localizeUploadDate(activity, streamInfo.getUploadDate().offsetDateTime()));
|
||||
} else {
|
||||
binding.detailUploadDateView.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void setupDescription() {
|
||||
final Description description = streamInfo.getDescription();
|
||||
if (description == null || isEmpty(description.getContent())
|
||||
|| description == Description.EMPTY_DESCRIPTION) {
|
||||
binding.detailDescriptionView.setVisibility(View.GONE);
|
||||
binding.detailSelectDescriptionButton.setVisibility(View.GONE);
|
||||
if (streamInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// start with disabled state. This also loads description content (!)
|
||||
disableDescriptionSelection();
|
||||
|
||||
binding.detailSelectDescriptionButton.setOnClickListener(v -> {
|
||||
if (binding.detailDescriptionNoteView.getVisibility() == View.VISIBLE) {
|
||||
disableDescriptionSelection();
|
||||
} else {
|
||||
// enable selection only when button is clicked to prevent flickering
|
||||
enableDescriptionSelection();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void enableDescriptionSelection() {
|
||||
binding.detailDescriptionNoteView.setVisibility(View.VISIBLE);
|
||||
binding.detailDescriptionView.setTextIsSelectable(true);
|
||||
|
||||
final String buttonLabel = getString(R.string.description_select_disable);
|
||||
binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
|
||||
TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
|
||||
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_close);
|
||||
}
|
||||
|
||||
private void disableDescriptionSelection() {
|
||||
// show description content again, otherwise some links are not clickable
|
||||
TextLinkifier.fromDescription(binding.detailDescriptionView,
|
||||
streamInfo.getDescription(), HtmlCompat.FROM_HTML_MODE_LEGACY,
|
||||
streamInfo.getService(), streamInfo.getUrl(),
|
||||
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
|
||||
|
||||
binding.detailDescriptionNoteView.setVisibility(View.GONE);
|
||||
binding.detailDescriptionView.setTextIsSelectable(false);
|
||||
|
||||
final String buttonLabel = getString(R.string.description_select_enable);
|
||||
binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
|
||||
TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
|
||||
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all);
|
||||
}
|
||||
|
||||
private void setupMetadata(final LayoutInflater inflater,
|
||||
final LinearLayout layout) {
|
||||
addMetadataItem(inflater, layout, false, R.string.metadata_category,
|
||||
streamInfo.getCategory());
|
||||
|
||||
@@ -151,69 +100,13 @@ public class DescriptionFragment extends BaseFragment {
|
||||
streamInfo.getSupportInfo());
|
||||
addMetadataItem(inflater, layout, true, R.string.metadata_host,
|
||||
streamInfo.getHost());
|
||||
addMetadataItem(inflater, layout, true, R.string.metadata_thumbnail_url,
|
||||
streamInfo.getThumbnailUrl());
|
||||
|
||||
addTagsMetadataItem(inflater, layout);
|
||||
}
|
||||
|
||||
private void addMetadataItem(final LayoutInflater inflater,
|
||||
final LinearLayout layout,
|
||||
final boolean linkifyContent,
|
||||
@StringRes final int type,
|
||||
@Nullable final String content) {
|
||||
if (isBlank(content)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final ItemMetadataBinding itemBinding =
|
||||
ItemMetadataBinding.inflate(inflater, layout, false);
|
||||
|
||||
itemBinding.metadataTypeView.setText(type);
|
||||
itemBinding.metadataTypeView.setOnLongClickListener(v -> {
|
||||
ShareUtils.copyToClipboard(requireContext(), content);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (linkifyContent) {
|
||||
TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null,
|
||||
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
|
||||
} else {
|
||||
itemBinding.metadataContentView.setText(content);
|
||||
}
|
||||
|
||||
itemBinding.metadataContentView.setClickable(true);
|
||||
|
||||
layout.addView(itemBinding.getRoot());
|
||||
}
|
||||
|
||||
private void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) {
|
||||
if (streamInfo.getTags() != null && !streamInfo.getTags().isEmpty()) {
|
||||
final var itemBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false);
|
||||
|
||||
streamInfo.getTags().stream().sorted(String.CASE_INSENSITIVE_ORDER).forEach(tag -> {
|
||||
final Chip chip = (Chip) inflater.inflate(R.layout.chip,
|
||||
itemBinding.metadataTagsChips, false);
|
||||
chip.setText(tag);
|
||||
chip.setOnClickListener(this::onTagClick);
|
||||
chip.setOnLongClickListener(this::onTagLongClick);
|
||||
itemBinding.metadataTagsChips.addView(chip);
|
||||
});
|
||||
|
||||
layout.addView(itemBinding.getRoot());
|
||||
}
|
||||
}
|
||||
|
||||
private void onTagClick(final View chip) {
|
||||
if (getParentFragment() != null) {
|
||||
NavigationHelper.openSearchFragment(getParentFragment().getParentFragmentManager(),
|
||||
streamInfo.getServiceId(), ((Chip) chip).getText().toString());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean onTagLongClick(final View chip) {
|
||||
ShareUtils.copyToClipboard(requireContext(), ((Chip) chip).getText().toString());
|
||||
return true;
|
||||
addImagesMetadataItem(inflater, layout, R.string.metadata_thumbnails,
|
||||
streamInfo.getThumbnails());
|
||||
addImagesMetadataItem(inflater, layout, R.string.metadata_uploader_avatars,
|
||||
streamInfo.getUploaderAvatars());
|
||||
addImagesMetadataItem(inflater, layout, R.string.metadata_subchannel_avatars,
|
||||
streamInfo.getSubChannelAvatars());
|
||||
}
|
||||
|
||||
private void addPrivacyMetadataItem(final LayoutInflater inflater, final LinearLayout layout) {
|
||||
|
||||
@@ -24,7 +24,6 @@ import android.content.pm.ActivityInfo;
|
||||
import android.database.ContentObserver;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
@@ -54,6 +53,7 @@ import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import com.google.android.exoplayer2.PlaybackException;
|
||||
@@ -71,8 +71,9 @@ import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.ReCaptchaActivity;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.Image;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.extractor.stream.AudioStream;
|
||||
@@ -83,11 +84,13 @@ import org.schabi.newpipe.extractor.stream.VideoStream;
|
||||
import org.schabi.newpipe.fragments.BackPressable;
|
||||
import org.schabi.newpipe.fragments.BaseStateFragment;
|
||||
import org.schabi.newpipe.fragments.EmptyFragment;
|
||||
import org.schabi.newpipe.fragments.MainFragment;
|
||||
import org.schabi.newpipe.fragments.list.comments.CommentsFragment;
|
||||
import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment;
|
||||
import org.schabi.newpipe.ktx.AnimationType;
|
||||
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
|
||||
import org.schabi.newpipe.player.Player;
|
||||
import org.schabi.newpipe.player.PlayerService;
|
||||
import org.schabi.newpipe.player.PlayerType;
|
||||
@@ -103,15 +106,17 @@ import org.schabi.newpipe.player.ui.VideoPlayerUi;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.InfoCache;
|
||||
import org.schabi.newpipe.util.ListHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.PermissionHelper;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.PlayButtonHelper;
|
||||
import org.schabi.newpipe.util.StreamTypeUtil;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
@@ -470,10 +475,23 @@ public final class VideoDetailFragment
|
||||
|
||||
binding.detailControlsBackground.setOnClickListener(v -> openBackgroundPlayer(false));
|
||||
binding.detailControlsPopup.setOnClickListener(v -> openPopupPlayer(false));
|
||||
binding.detailControlsPlaylistAppend.setOnClickListener(makeOnClickListener(info ->
|
||||
binding.detailControlsPlaylistAppend.setOnClickListener(makeOnClickListener(info -> {
|
||||
if (getFM() != null && currentInfo != null) {
|
||||
final Fragment fragment = getParentFragmentManager().
|
||||
findFragmentById(R.id.fragment_holder);
|
||||
|
||||
// commit previous pending changes to database
|
||||
if (fragment instanceof LocalPlaylistFragment) {
|
||||
((LocalPlaylistFragment) fragment).saveImmediate();
|
||||
} else if (fragment instanceof MainFragment) {
|
||||
((MainFragment) fragment).commitPlaylistTabs();
|
||||
}
|
||||
|
||||
disposables.add(PlaylistDialog.createCorrespondingDialog(requireContext(),
|
||||
List.of(new StreamEntity(info)),
|
||||
dialog -> dialog.show(getParentFragmentManager(), TAG)))));
|
||||
dialog -> dialog.show(getParentFragmentManager(), TAG)));
|
||||
}
|
||||
}));
|
||||
binding.detailControlsDownload.setOnClickListener(v -> {
|
||||
if (PermissionHelper.checkStoragePermissions(activity,
|
||||
PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) {
|
||||
@@ -482,7 +500,7 @@ public final class VideoDetailFragment
|
||||
});
|
||||
binding.detailControlsShare.setOnClickListener(makeOnClickListener(info ->
|
||||
ShareUtils.shareText(requireContext(), info.getName(), info.getUrl(),
|
||||
info.getThumbnailUrl())));
|
||||
info.getThumbnails())));
|
||||
binding.detailControlsOpenInBrowser.setOnClickListener(makeOnClickListener(info ->
|
||||
ShareUtils.openUrlInBrowser(requireContext(), info.getUrl())));
|
||||
binding.detailControlsPlayWithKodi.setOnClickListener(makeOnClickListener(info ->
|
||||
@@ -535,9 +553,11 @@ public final class VideoDetailFragment
|
||||
}));
|
||||
|
||||
binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info ->
|
||||
openBackgroundPlayer(true)));
|
||||
openBackgroundPlayer(true)
|
||||
));
|
||||
binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info ->
|
||||
openPopupPlayer(true)));
|
||||
openPopupPlayer(true)
|
||||
));
|
||||
binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info ->
|
||||
NavigationHelper.openDownloads(activity)));
|
||||
|
||||
@@ -620,8 +640,7 @@ public final class VideoDetailFragment
|
||||
|
||||
final View.OnTouchListener controlsTouchListener = (view, motionEvent) -> {
|
||||
if (motionEvent.getAction() == MotionEvent.ACTION_DOWN
|
||||
&& PreferenceManager.getDefaultSharedPreferences(activity)
|
||||
.getBoolean(getString(R.string.show_hold_to_append_key), true)) {
|
||||
&& PlayButtonHelper.shouldShowHoldToAppendTip(activity)) {
|
||||
|
||||
animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, 0, () ->
|
||||
animate(binding.touchAppendDetail, false, 1500, AnimationType.ALPHA, 1000));
|
||||
@@ -721,7 +740,7 @@ public final class VideoDetailFragment
|
||||
final boolean isPlayerStopped = !isPlayerAvailable() || player.isStopped();
|
||||
if (playQueueItem != null && isPlayerStopped) {
|
||||
updateOverlayData(playQueueItem.getTitle(),
|
||||
playQueueItem.getUploader(), playQueueItem.getThumbnailUrl());
|
||||
playQueueItem.getUploader(), playQueueItem.getThumbnails());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -994,6 +1013,20 @@ public final class VideoDetailFragment
|
||||
updateTabLayoutVisibility();
|
||||
}
|
||||
|
||||
public void scrollToComment(final CommentsInfoItem comment) {
|
||||
final int commentsTabPos = pageAdapter.getItemPositionByTitle(COMMENTS_TAB_TAG);
|
||||
final Fragment fragment = pageAdapter.getItem(commentsTabPos);
|
||||
if (!(fragment instanceof CommentsFragment)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// unexpand the app bar only if scrolling to the comment succeeded
|
||||
if (((CommentsFragment) fragment).scrollToComment(comment)) {
|
||||
binding.appBarLayout.setExpanded(false, false);
|
||||
binding.viewPager.setCurrentItem(commentsTabPos, false);
|
||||
}
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Play Utils
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@@ -1412,7 +1445,7 @@ public final class VideoDetailFragment
|
||||
super.showLoading();
|
||||
|
||||
//if data is already cached, transition from VISIBLE -> INVISIBLE -> VISIBLE is not required
|
||||
if (!ExtractorHelper.isCached(serviceId, url, InfoItem.InfoType.STREAM)) {
|
||||
if (!ExtractorHelper.isCached(serviceId, url, InfoCache.Type.STREAM)) {
|
||||
binding.detailContentRootHiding.setVisibility(View.INVISIBLE);
|
||||
}
|
||||
|
||||
@@ -1463,11 +1496,6 @@ public final class VideoDetailFragment
|
||||
displayUploaderAsSubChannel(info);
|
||||
}
|
||||
|
||||
final Drawable buddyDrawable =
|
||||
AppCompatResources.getDrawable(activity, R.drawable.placeholder_person);
|
||||
binding.detailSubChannelThumbnailView.setImageDrawable(buddyDrawable);
|
||||
binding.detailUploaderThumbnailView.setImageDrawable(buddyDrawable);
|
||||
|
||||
if (info.getViewCount() >= 0) {
|
||||
if (info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) {
|
||||
binding.detailViewCountView.setText(Localization.listeningCount(activity,
|
||||
@@ -1534,13 +1562,13 @@ public final class VideoDetailFragment
|
||||
binding.detailSecondaryControlPanel.setVisibility(View.GONE);
|
||||
|
||||
checkUpdateProgressInfo(info);
|
||||
PicassoHelper.loadDetailsThumbnail(info.getThumbnailUrl()).tag(PICASSO_VIDEO_DETAILS_TAG)
|
||||
PicassoHelper.loadDetailsThumbnail(info.getThumbnails()).tag(PICASSO_VIDEO_DETAILS_TAG)
|
||||
.into(binding.detailThumbnailImageView);
|
||||
showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView,
|
||||
binding.detailMetaInfoSeparator, disposables);
|
||||
|
||||
if (!isPlayerAvailable() || player.isStopped()) {
|
||||
updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnailUrl());
|
||||
updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails());
|
||||
}
|
||||
|
||||
if (!info.getErrors().isEmpty()) {
|
||||
@@ -1585,7 +1613,7 @@ public final class VideoDetailFragment
|
||||
binding.detailUploaderTextView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
PicassoHelper.loadAvatar(info.getUploaderAvatarUrl()).tag(PICASSO_VIDEO_DETAILS_TAG)
|
||||
PicassoHelper.loadAvatar(info.getUploaderAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG)
|
||||
.into(binding.detailSubChannelThumbnailView);
|
||||
binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE);
|
||||
binding.detailUploaderThumbnailView.setVisibility(View.GONE);
|
||||
@@ -1617,10 +1645,10 @@ public final class VideoDetailFragment
|
||||
binding.detailUploaderTextView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
PicassoHelper.loadAvatar(info.getSubChannelAvatarUrl()).tag(PICASSO_VIDEO_DETAILS_TAG)
|
||||
PicassoHelper.loadAvatar(info.getSubChannelAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG)
|
||||
.into(binding.detailSubChannelThumbnailView);
|
||||
binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE);
|
||||
PicassoHelper.loadAvatar(info.getUploaderAvatarUrl()).tag(PICASSO_VIDEO_DETAILS_TAG)
|
||||
PicassoHelper.loadAvatar(info.getUploaderAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG)
|
||||
.into(binding.detailUploaderThumbnailView);
|
||||
binding.detailUploaderThumbnailView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
@@ -1795,7 +1823,7 @@ public final class VideoDetailFragment
|
||||
return;
|
||||
}
|
||||
|
||||
updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnailUrl());
|
||||
updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails());
|
||||
if (currentInfo != null && info.getUrl().equals(currentInfo.getUrl())) {
|
||||
return;
|
||||
}
|
||||
@@ -1824,7 +1852,7 @@ public final class VideoDetailFragment
|
||||
if (currentInfo != null) {
|
||||
updateOverlayData(currentInfo.getName(),
|
||||
currentInfo.getUploaderName(),
|
||||
currentInfo.getThumbnailUrl());
|
||||
currentInfo.getThumbnails());
|
||||
}
|
||||
updateOverlayPlayQueueButtonVisibility();
|
||||
}
|
||||
@@ -2189,7 +2217,7 @@ public final class VideoDetailFragment
|
||||
playerHolder.stopService();
|
||||
setInitialData(0, null, "", null);
|
||||
currentInfo = null;
|
||||
updateOverlayData(null, null, null);
|
||||
updateOverlayData(null, null, List.of());
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
@@ -2371,11 +2399,11 @@ public final class VideoDetailFragment
|
||||
|
||||
private void updateOverlayData(@Nullable final String overlayTitle,
|
||||
@Nullable final String uploader,
|
||||
@Nullable final String thumbnailUrl) {
|
||||
@NonNull final List<Image> thumbnails) {
|
||||
binding.overlayTitleTextView.setText(isEmpty(overlayTitle) ? "" : overlayTitle);
|
||||
binding.overlayChannelTextView.setText(isEmpty(uploader) ? "" : uploader);
|
||||
binding.overlayThumbnail.setImageDrawable(null);
|
||||
PicassoHelper.loadDetailsThumbnail(thumbnailUrl).tag(PICASSO_VIDEO_DETAILS_TAG)
|
||||
PicassoHelper.loadDetailsThumbnail(thumbnails).tag(PICASSO_VIDEO_DETAILS_TAG)
|
||||
.into(binding.overlayThumbnail);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.schabi.newpipe.fragments.list;
|
||||
|
||||
import static org.schabi.newpipe.extractor.ServiceList.SoundCloud;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
@@ -7,13 +9,13 @@ import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.ListInfo;
|
||||
import org.schabi.newpipe.extractor.Page;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.views.NewPipeRecyclerView;
|
||||
@@ -229,13 +231,11 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
|
||||
if (!result.getRelatedItems().isEmpty()) {
|
||||
infoListAdapter.addInfoItemList(result.getRelatedItems());
|
||||
showListFooter(hasMoreItems());
|
||||
} else if (hasMoreItems()) {
|
||||
loadMoreItems();
|
||||
} else {
|
||||
infoListAdapter.clearStreamItemList();
|
||||
// showEmptyState should be called only if there is no item as
|
||||
// well as no header in infoListAdapter
|
||||
if (!(result instanceof ChannelInfo && infoListAdapter.getItemCount() == 1)) {
|
||||
showEmptyState();
|
||||
}
|
||||
showEmptyState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +252,20 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showEmptyState() {
|
||||
// show "no streams" for SoundCloud; otherwise "no videos"
|
||||
// showing "no live streams" is handled in KioskFragment
|
||||
if (emptyStateView != null) {
|
||||
if (currentInfo.getService() == SoundCloud) {
|
||||
setEmptyStateMessage(R.string.no_streams);
|
||||
} else {
|
||||
setEmptyStateMessage(R.string.no_videos);
|
||||
}
|
||||
}
|
||||
super.showEmptyState();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Utils
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package org.schabi.newpipe.fragments.list.channel;
|
||||
|
||||
import static org.schabi.newpipe.extractor.stream.StreamExtractor.UNKNOWN_SUBSCRIBER_COUNT;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.extractor.stream.Description;
|
||||
import org.schabi.newpipe.fragments.detail.BaseDescriptionFragment;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import icepick.State;
|
||||
|
||||
public class ChannelAboutFragment extends BaseDescriptionFragment {
|
||||
@State
|
||||
protected ChannelInfo channelInfo;
|
||||
|
||||
ChannelAboutFragment(@NonNull final ChannelInfo channelInfo) {
|
||||
this.channelInfo = channelInfo;
|
||||
}
|
||||
|
||||
public ChannelAboutFragment() {
|
||||
// keep empty constructor for IcePick when resuming fragment from memory
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
||||
super.initViews(rootView, savedInstanceState);
|
||||
binding.constraintLayout.setPadding(0, DeviceUtils.dpToPx(8, requireContext()), 0, 0);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
protected Description getDescription() {
|
||||
return new Description(channelInfo.getDescription(), Description.PLAIN_TEXT);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
protected StreamingService getService() {
|
||||
return channelInfo.getService();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getServiceId() {
|
||||
return channelInfo.getServiceId();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
protected String getStreamUrl() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public List<String> getTags() {
|
||||
return channelInfo.getTags();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void setupMetadata(final LayoutInflater inflater,
|
||||
final LinearLayout layout) {
|
||||
// There is no upload date available for channels, so hide the relevant UI element
|
||||
binding.detailUploadDateView.setVisibility(View.GONE);
|
||||
|
||||
if (channelInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (channelInfo.getSubscriberCount() != UNKNOWN_SUBSCRIBER_COUNT) {
|
||||
addMetadataItem(inflater, layout, false, R.string.metadata_subscribers,
|
||||
Localization.localizeNumber(
|
||||
requireContext(),
|
||||
channelInfo.getSubscriberCount()));
|
||||
}
|
||||
|
||||
addImagesMetadataItem(inflater, layout, R.string.metadata_avatars,
|
||||
channelInfo.getAvatars());
|
||||
addImagesMetadataItem(inflater, layout, R.string.metadata_banners,
|
||||
channelInfo.getBanners());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Color;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
@@ -16,51 +17,50 @@ import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.graphics.ColorUtils;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
import com.google.android.material.tabs.TabLayout;
|
||||
import com.jakewharton.rxbinding4.view.RxView;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.databinding.ChannelHeaderBinding;
|
||||
import org.schabi.newpipe.databinding.FragmentChannelBinding;
|
||||
import org.schabi.newpipe.databinding.PlaylistControlBinding;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
|
||||
import org.schabi.newpipe.fragments.BaseStateFragment;
|
||||
import org.schabi.newpipe.fragments.detail.TabAdapter;
|
||||
import org.schabi.newpipe.ktx.AnimationType;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||
import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
|
||||
import org.schabi.newpipe.player.PlayerType;
|
||||
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||
import org.schabi.newpipe.util.ChannelTabHelper;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.StateSaver;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import icepick.State;
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Observable;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.functions.Action;
|
||||
@@ -68,29 +68,37 @@ import io.reactivex.rxjava3.functions.Consumer;
|
||||
import io.reactivex.rxjava3.functions.Function;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, ChannelInfo>
|
||||
implements View.OnClickListener {
|
||||
public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
||||
implements StateSaver.WriteRead {
|
||||
|
||||
private static final int BUTTON_DEBOUNCE_INTERVAL = 100;
|
||||
private static final String PICASSO_CHANNEL_TAG = "PICASSO_CHANNEL_TAG";
|
||||
|
||||
@State
|
||||
protected int serviceId = Constants.NO_SERVICE_ID;
|
||||
@State
|
||||
protected String name;
|
||||
@State
|
||||
protected String url;
|
||||
|
||||
private ChannelInfo currentInfo;
|
||||
private Disposable currentWorker;
|
||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||
private Disposable subscribeButtonMonitor;
|
||||
|
||||
private SubscriptionManager subscriptionManager;
|
||||
private int lastTab;
|
||||
private boolean channelContentNotSupported = false;
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Views
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private SubscriptionManager subscriptionManager;
|
||||
|
||||
private FragmentChannelBinding channelBinding;
|
||||
private ChannelHeaderBinding headerBinding;
|
||||
private PlaylistControlBinding playlistControlBinding;
|
||||
private FragmentChannelBinding binding;
|
||||
private TabAdapter tabAdapter;
|
||||
|
||||
private MenuItem menuRssButton;
|
||||
private MenuItem menuNotifyButton;
|
||||
private SubscriptionEntity channelSubscription;
|
||||
|
||||
public static ChannelFragment getInstance(final int serviceId, final String url,
|
||||
final String name) {
|
||||
@@ -99,22 +107,23 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
return instance;
|
||||
}
|
||||
|
||||
public ChannelFragment() {
|
||||
super(UserAction.REQUESTED_CHANNEL);
|
||||
private void setInitialData(final int sid, final String u, final String title) {
|
||||
this.serviceId = sid;
|
||||
this.url = u;
|
||||
this.name = !TextUtils.isEmpty(title) ? title : "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
if (activity != null && useAsFrontPage) {
|
||||
setTitle(currentInfo != null ? currentInfo.getName() : name);
|
||||
}
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// LifeCycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCreate(final Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setHasOptionsMenu(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull final Context context) {
|
||||
super.onAttach(context);
|
||||
@@ -125,49 +134,58 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
public View onCreateView(@NonNull final LayoutInflater inflater,
|
||||
@Nullable final ViewGroup container,
|
||||
@Nullable final Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_channel, container, false);
|
||||
binding = FragmentChannelBinding.inflate(inflater, container, false);
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
|
||||
super.onViewCreated(rootView, savedInstanceState);
|
||||
channelBinding = FragmentChannelBinding.bind(rootView);
|
||||
showContentNotSupportedIfNeeded();
|
||||
}
|
||||
@Override // called from onViewCreated in BaseFragment.onViewCreated
|
||||
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
||||
super.initViews(rootView, savedInstanceState);
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
disposables.clear();
|
||||
if (subscribeButtonMonitor != null) {
|
||||
subscribeButtonMonitor.dispose();
|
||||
tabAdapter = new TabAdapter(getChildFragmentManager());
|
||||
binding.viewPager.setAdapter(tabAdapter);
|
||||
binding.tabLayout.setupWithViewPager(binding.viewPager);
|
||||
|
||||
setTitle(name);
|
||||
binding.channelTitleView.setText(name);
|
||||
if (!ImageStrategy.shouldLoadImages()) {
|
||||
// do not waste space for the banner if it is not going to be loaded
|
||||
binding.channelBannerImage.setImageDrawable(null);
|
||||
}
|
||||
channelBinding = null;
|
||||
headerBinding = null;
|
||||
playlistControlBinding = null;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Init
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected Supplier<View> getListHeaderSupplier() {
|
||||
headerBinding = ChannelHeaderBinding
|
||||
.inflate(activity.getLayoutInflater(), itemsList, false);
|
||||
playlistControlBinding = headerBinding.playlistControl;
|
||||
|
||||
return headerBinding::getRoot;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initListeners() {
|
||||
super.initListeners();
|
||||
|
||||
headerBinding.subChannelTitleView.setOnClickListener(this);
|
||||
headerBinding.subChannelAvatarView.setOnClickListener(this);
|
||||
final View.OnClickListener openSubChannel = v -> {
|
||||
if (!TextUtils.isEmpty(currentInfo.getParentChannelUrl())) {
|
||||
try {
|
||||
NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(),
|
||||
currentInfo.getParentChannelUrl(),
|
||||
currentInfo.getParentChannelName());
|
||||
} catch (final Exception e) {
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e);
|
||||
}
|
||||
} else if (DEBUG) {
|
||||
Log.i(TAG, "Can't open parent channel because we got no channel URL");
|
||||
}
|
||||
};
|
||||
binding.subChannelAvatarView.setOnClickListener(openSubChannel);
|
||||
binding.subChannelTitleView.setOnClickListener(openSubChannel);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
if (currentWorker != null) {
|
||||
currentWorker.dispose();
|
||||
}
|
||||
disposables.clear();
|
||||
binding = null;
|
||||
}
|
||||
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Menu
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@@ -176,32 +194,33 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
super.onCreateOptionsMenu(menu, inflater);
|
||||
final ActionBar supportActionBar = activity.getSupportActionBar();
|
||||
if (useAsFrontPage && supportActionBar != null) {
|
||||
supportActionBar.setDisplayHomeAsUpEnabled(false);
|
||||
} else {
|
||||
inflater.inflate(R.menu.menu_channel, menu);
|
||||
inflater.inflate(R.menu.menu_channel, menu);
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreateOptionsMenu() called with: "
|
||||
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||
}
|
||||
menuRssButton = menu.findItem(R.id.menu_item_rss);
|
||||
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreateOptionsMenu() called with: "
|
||||
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(final MenuItem item) {
|
||||
public void onPrepareOptionsMenu(@NonNull final Menu menu) {
|
||||
super.onPrepareOptionsMenu(menu);
|
||||
menuRssButton = menu.findItem(R.id.menu_item_rss);
|
||||
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
|
||||
updateNotifyButton(channelSubscription);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case R.id.action_settings:
|
||||
NavigationHelper.openSettings(requireContext());
|
||||
break;
|
||||
case R.id.menu_item_notify:
|
||||
final boolean value = !item.isChecked();
|
||||
item.setEnabled(false);
|
||||
setNotify(value);
|
||||
break;
|
||||
case R.id.action_settings:
|
||||
NavigationHelper.openSettings(requireContext());
|
||||
break;
|
||||
case R.id.menu_item_rss:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl());
|
||||
@@ -215,7 +234,7 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
case R.id.menu_item_share:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.shareText(requireContext(), name, currentInfo.getOriginalUrl(),
|
||||
currentInfo.getAvatarUrl());
|
||||
currentInfo.getAvatars());
|
||||
}
|
||||
break;
|
||||
default:
|
||||
@@ -224,13 +243,14 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Channel Subscription
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private void monitorSubscription(final ChannelInfo info) {
|
||||
final Consumer<Throwable> onError = (Throwable throwable) -> {
|
||||
animate(headerBinding.channelSubscribeButton, false, 100);
|
||||
animate(binding.channelSubscribeButton, false, 100);
|
||||
showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_GET,
|
||||
"Get subscription status", currentInfo));
|
||||
};
|
||||
@@ -263,10 +283,9 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
}, onError));
|
||||
}
|
||||
|
||||
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription,
|
||||
final ChannelInfo info) {
|
||||
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription) {
|
||||
return (@NonNull Object o) -> {
|
||||
subscriptionManager.insertSubscription(subscription, info);
|
||||
subscriptionManager.insertSubscription(subscription);
|
||||
return o;
|
||||
};
|
||||
}
|
||||
@@ -298,8 +317,7 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
.subscribe(onComplete, onError));
|
||||
}
|
||||
|
||||
private Disposable monitorSubscribeButton(final Button subscribeButton,
|
||||
final Function<Object, Object> action) {
|
||||
private Disposable monitorSubscribeButton(final Function<Object, Object> action) {
|
||||
final Consumer<Object> onNext = (@NonNull Object o) -> {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Changed subscription status to this channel!");
|
||||
@@ -311,7 +329,7 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
"Changing subscription for " + currentInfo.getUrl(), currentInfo));
|
||||
|
||||
/* Emit clicks from main thread unto io thread */
|
||||
return RxView.clicks(subscribeButton)
|
||||
return RxView.clicks(binding.channelSubscribeButton)
|
||||
.subscribeOn(AndroidSchedulers.mainThread())
|
||||
.observeOn(Schedulers.io())
|
||||
.debounce(BUTTON_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) // Ignore rapid clicks
|
||||
@@ -337,20 +355,20 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
channel.setServiceId(info.getServiceId());
|
||||
channel.setUrl(info.getUrl());
|
||||
channel.setData(info.getName(),
|
||||
info.getAvatarUrl(),
|
||||
ImageStrategy.imageListToDbUrl(info.getAvatars()),
|
||||
info.getDescription(),
|
||||
info.getSubscriberCount());
|
||||
channelSubscription = null;
|
||||
updateNotifyButton(null);
|
||||
subscribeButtonMonitor = monitorSubscribeButton(
|
||||
headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info));
|
||||
subscribeButtonMonitor = monitorSubscribeButton(mapOnSubscribe(channel));
|
||||
} else {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Found subscription to this channel!");
|
||||
}
|
||||
final SubscriptionEntity subscription = subscriptionEntities.get(0);
|
||||
updateNotifyButton(subscription);
|
||||
subscribeButtonMonitor = monitorSubscribeButton(
|
||||
headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription));
|
||||
channelSubscription = subscriptionEntities.get(0);
|
||||
updateNotifyButton(channelSubscription);
|
||||
subscribeButtonMonitor =
|
||||
monitorSubscribeButton(mapOnUnsubscribe(channelSubscription));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -361,34 +379,33 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
+ "isSubscribed = [" + isSubscribed + "]");
|
||||
}
|
||||
|
||||
final boolean isButtonVisible = headerBinding.channelSubscribeButton.getVisibility()
|
||||
final boolean isButtonVisible = binding.channelSubscribeButton.getVisibility()
|
||||
== View.VISIBLE;
|
||||
final int backgroundDuration = isButtonVisible ? 300 : 0;
|
||||
final int textDuration = isButtonVisible ? 200 : 0;
|
||||
|
||||
final int subscribeBackground = ThemeHelper
|
||||
.resolveColorFromAttr(activity, R.attr.colorPrimary);
|
||||
final int subscribeText = ContextCompat.getColor(activity, R.color.subscribe_text_color);
|
||||
final int subscribedBackground = ContextCompat
|
||||
.getColor(activity, R.color.subscribed_background_color);
|
||||
final int subscribedText = ContextCompat.getColor(activity, R.color.subscribed_text_color);
|
||||
final int subscribeBackground = ColorUtils.blendARGB(ThemeHelper
|
||||
.resolveColorFromAttr(activity, R.attr.colorPrimary), subscribedBackground, 0.35f);
|
||||
final int subscribeText = ContextCompat.getColor(activity, R.color.subscribe_text_color);
|
||||
|
||||
if (!isSubscribed) {
|
||||
headerBinding.channelSubscribeButton.setText(R.string.subscribe_button_title);
|
||||
animateBackgroundColor(headerBinding.channelSubscribeButton, backgroundDuration,
|
||||
subscribedBackground, subscribeBackground);
|
||||
animateTextColor(headerBinding.channelSubscribeButton, textDuration, subscribedText,
|
||||
subscribeText);
|
||||
} else {
|
||||
headerBinding.channelSubscribeButton.setText(R.string.subscribed_button_title);
|
||||
animateBackgroundColor(headerBinding.channelSubscribeButton, backgroundDuration,
|
||||
if (isSubscribed) {
|
||||
binding.channelSubscribeButton.setText(R.string.subscribed_button_title);
|
||||
animateBackgroundColor(binding.channelSubscribeButton, backgroundDuration,
|
||||
subscribeBackground, subscribedBackground);
|
||||
animateTextColor(headerBinding.channelSubscribeButton, textDuration, subscribeText,
|
||||
animateTextColor(binding.channelSubscribeButton, textDuration, subscribeText,
|
||||
subscribedText);
|
||||
} else {
|
||||
binding.channelSubscribeButton.setText(R.string.subscribe_button_title);
|
||||
animateBackgroundColor(binding.channelSubscribeButton, backgroundDuration,
|
||||
subscribedBackground, subscribeBackground);
|
||||
animateTextColor(binding.channelSubscribeButton, textDuration, subscribedText,
|
||||
subscribeText);
|
||||
}
|
||||
|
||||
animate(headerBinding.channelSubscribeButton, true, 100,
|
||||
AnimationType.LIGHT_SCALE_AND_ALPHA);
|
||||
animate(binding.channelSubscribeButton, true, 100, AnimationType.LIGHT_SCALE_AND_ALPHA);
|
||||
}
|
||||
|
||||
private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) {
|
||||
@@ -424,108 +441,179 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
* Show a snackbar with the option to enable notifications on new streams for this channel.
|
||||
*/
|
||||
private void showNotifySnackbar() {
|
||||
Snackbar.make(itemsList, R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG)
|
||||
Snackbar.make(binding.getRoot(), R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.get_notified, v -> setNotify(true))
|
||||
.setActionTextColor(Color.YELLOW)
|
||||
.show();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Load and handle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
|
||||
return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPage);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Single<ChannelInfo> loadResult(final boolean forceLoad) {
|
||||
return ExtractorHelper.getChannelInfo(serviceId, url, forceLoad);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// OnClick
|
||||
// Init
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onClick(final View v) {
|
||||
if (isLoading.get() || currentInfo == null) {
|
||||
return;
|
||||
}
|
||||
private void updateTabs() {
|
||||
tabAdapter.clearAllItems();
|
||||
|
||||
switch (v.getId()) {
|
||||
case R.id.sub_channel_avatar_view:
|
||||
case R.id.sub_channel_title_view:
|
||||
if (!TextUtils.isEmpty(currentInfo.getParentChannelUrl())) {
|
||||
try {
|
||||
NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(),
|
||||
currentInfo.getParentChannelUrl(),
|
||||
currentInfo.getParentChannelName());
|
||||
} catch (final Exception e) {
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e);
|
||||
}
|
||||
} else if (DEBUG) {
|
||||
Log.i(TAG, "Can't open parent channel because we got no channel URL");
|
||||
if (currentInfo != null && !channelContentNotSupported) {
|
||||
final Context context = requireContext();
|
||||
final SharedPreferences preferences = PreferenceManager
|
||||
.getDefaultSharedPreferences(context);
|
||||
|
||||
for (final ListLinkHandler linkHandler : currentInfo.getTabs()) {
|
||||
final String tab = linkHandler.getContentFilters().get(0);
|
||||
if (ChannelTabHelper.showChannelTab(context, preferences, tab)) {
|
||||
final ChannelTabFragment channelTabFragment =
|
||||
ChannelTabFragment.getInstance(serviceId, linkHandler, name);
|
||||
channelTabFragment.useAsFrontPage(useAsFrontPage);
|
||||
tabAdapter.addFragment(channelTabFragment,
|
||||
context.getString(ChannelTabHelper.getTranslationKey(tab)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (ChannelTabHelper.showChannelTab(
|
||||
context, preferences, R.string.show_channel_tabs_about)) {
|
||||
tabAdapter.addFragment(
|
||||
new ChannelAboutFragment(currentInfo),
|
||||
context.getString(R.string.channel_tab_about));
|
||||
}
|
||||
}
|
||||
|
||||
tabAdapter.notifyDataSetUpdate();
|
||||
|
||||
for (int i = 0; i < tabAdapter.getCount(); i++) {
|
||||
binding.tabLayout.getTabAt(i).setText(tabAdapter.getItemTitle(i));
|
||||
}
|
||||
|
||||
// Restore previously selected tab
|
||||
final TabLayout.Tab ltab = binding.tabLayout.getTabAt(lastTab);
|
||||
if (ltab != null) {
|
||||
binding.tabLayout.selectTab(ltab);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// State Saving
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public String generateSuffix() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeTo(final Queue<Object> objectsToSave) {
|
||||
objectsToSave.add(currentInfo);
|
||||
objectsToSave.add(binding == null ? 0 : binding.tabLayout.getSelectedTabPosition());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void readFrom(@NonNull final Queue<Object> savedObjects) {
|
||||
currentInfo = (ChannelInfo) savedObjects.poll();
|
||||
lastTab = (Integer) savedObjects.poll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(final @NonNull Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
if (binding != null) {
|
||||
outState.putInt("LastTab", binding.tabLayout.getSelectedTabPosition());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) {
|
||||
super.onRestoreInstanceState(savedInstanceState);
|
||||
lastTab = savedInstanceState.getInt("LastTab", 0);
|
||||
}
|
||||
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Contract
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected void doInitialLoadLogic() {
|
||||
if (currentInfo == null) {
|
||||
startLoading(false);
|
||||
} else {
|
||||
handleResult(currentInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startLoading(final boolean forceLoad) {
|
||||
super.startLoading(forceLoad);
|
||||
|
||||
currentInfo = null;
|
||||
updateTabs();
|
||||
if (currentWorker != null) {
|
||||
currentWorker.dispose();
|
||||
}
|
||||
|
||||
runWorker(forceLoad);
|
||||
}
|
||||
|
||||
private void runWorker(final boolean forceLoad) {
|
||||
currentWorker = ExtractorHelper.getChannelInfo(serviceId, url, forceLoad)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(result -> {
|
||||
isLoading.set(false);
|
||||
handleResult(result);
|
||||
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_CHANNEL,
|
||||
url == null ? "No URL" : url, serviceId)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showLoading() {
|
||||
super.showLoading();
|
||||
PicassoHelper.cancelTag(PICASSO_CHANNEL_TAG);
|
||||
animate(headerBinding.channelSubscribeButton, false, 100);
|
||||
animate(binding.channelSubscribeButton, false, 100);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleResult(@NonNull final ChannelInfo result) {
|
||||
super.handleResult(result);
|
||||
currentInfo = result;
|
||||
setInitialData(result.getServiceId(), result.getOriginalUrl(), result.getName());
|
||||
|
||||
headerBinding.getRoot().setVisibility(View.VISIBLE);
|
||||
PicassoHelper.loadBanner(result.getBannerUrl()).tag(PICASSO_CHANNEL_TAG)
|
||||
.into(headerBinding.channelBannerImage);
|
||||
PicassoHelper.loadAvatar(result.getAvatarUrl()).tag(PICASSO_CHANNEL_TAG)
|
||||
.into(headerBinding.channelAvatarView);
|
||||
PicassoHelper.loadAvatar(result.getParentChannelAvatarUrl()).tag(PICASSO_CHANNEL_TAG)
|
||||
.into(headerBinding.subChannelAvatarView);
|
||||
if (ImageStrategy.shouldLoadImages() && !result.getBanners().isEmpty()) {
|
||||
PicassoHelper.loadBanner(result.getBanners()).tag(PICASSO_CHANNEL_TAG)
|
||||
.into(binding.channelBannerImage);
|
||||
} else {
|
||||
// do not waste space for the banner, if the user disabled images or there is not one
|
||||
binding.channelBannerImage.setImageDrawable(null);
|
||||
}
|
||||
|
||||
headerBinding.channelSubscriberView.setVisibility(View.VISIBLE);
|
||||
PicassoHelper.loadAvatar(result.getAvatars()).tag(PICASSO_CHANNEL_TAG)
|
||||
.into(binding.channelAvatarView);
|
||||
PicassoHelper.loadAvatar(result.getParentChannelAvatars()).tag(PICASSO_CHANNEL_TAG)
|
||||
.into(binding.subChannelAvatarView);
|
||||
|
||||
binding.channelTitleView.setText(result.getName());
|
||||
binding.channelSubscriberView.setVisibility(View.VISIBLE);
|
||||
if (result.getSubscriberCount() >= 0) {
|
||||
headerBinding.channelSubscriberView.setText(Localization
|
||||
binding.channelSubscriberView.setText(Localization
|
||||
.shortSubscriberCount(activity, result.getSubscriberCount()));
|
||||
} else {
|
||||
headerBinding.channelSubscriberView.setText(R.string.subscribers_count_not_available);
|
||||
binding.channelSubscriberView.setText(R.string.subscribers_count_not_available);
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(currentInfo.getParentChannelName())) {
|
||||
headerBinding.subChannelTitleView.setText(String.format(
|
||||
binding.subChannelTitleView.setText(String.format(
|
||||
getString(R.string.channel_created_by),
|
||||
currentInfo.getParentChannelName())
|
||||
);
|
||||
headerBinding.subChannelTitleView.setVisibility(View.VISIBLE);
|
||||
headerBinding.subChannelAvatarView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
headerBinding.subChannelTitleView.setVisibility(View.GONE);
|
||||
binding.subChannelTitleView.setVisibility(View.VISIBLE);
|
||||
binding.subChannelAvatarView.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
if (menuRssButton != null) {
|
||||
menuRssButton.setVisible(!TextUtils.isEmpty(result.getFeedUrl()));
|
||||
}
|
||||
|
||||
// PlaylistControls should be visible only if there is some item in
|
||||
// infoListAdapter other than header
|
||||
if (infoListAdapter.getItemCount() != 1) {
|
||||
playlistControlBinding.getRoot().setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
playlistControlBinding.getRoot().setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
channelContentNotSupported = false;
|
||||
for (final Throwable throwable : result.getErrors()) {
|
||||
if (throwable instanceof ContentNotSupportedException) {
|
||||
@@ -539,62 +627,21 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
|
||||
if (subscribeButtonMonitor != null) {
|
||||
subscribeButtonMonitor.dispose();
|
||||
}
|
||||
|
||||
updateTabs();
|
||||
updateSubscription(result);
|
||||
monitorSubscription(result);
|
||||
|
||||
playlistControlBinding.playlistCtrlPlayAllButton
|
||||
.setOnClickListener(view -> NavigationHelper
|
||||
.playOnMainPlayer(activity, getPlayQueue()));
|
||||
playlistControlBinding.playlistCtrlPlayPopupButton
|
||||
.setOnClickListener(view -> NavigationHelper
|
||||
.playOnPopupPlayer(activity, getPlayQueue(), false));
|
||||
playlistControlBinding.playlistCtrlPlayBgButton
|
||||
.setOnClickListener(view -> NavigationHelper
|
||||
.playOnBackgroundPlayer(activity, getPlayQueue(), false));
|
||||
|
||||
playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> {
|
||||
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.POPUP);
|
||||
return true;
|
||||
});
|
||||
|
||||
playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> {
|
||||
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.AUDIO);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private void showContentNotSupportedIfNeeded() {
|
||||
// channelBinding might not be initialized when handleResult() is called
|
||||
// (e.g. after rotating the screen, #6696)
|
||||
if (!channelContentNotSupported || channelBinding == null) {
|
||||
if (!channelContentNotSupported || binding == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
channelBinding.errorContentNotSupported.setVisibility(View.VISIBLE);
|
||||
channelBinding.channelKaomoji.setText("(︶︹︺)");
|
||||
channelBinding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f);
|
||||
channelBinding.channelNoVideos.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private PlayQueue getPlayQueue() {
|
||||
final List<StreamInfoItem> streamItems = infoListAdapter.getItemsList().stream()
|
||||
.filter(StreamInfoItem.class::isInstance)
|
||||
.map(StreamInfoItem.class::cast)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return new ChannelPlayQueue(currentInfo.getServiceId(), currentInfo.getUrl(),
|
||||
currentInfo.getNextPage(), streamItems, 0);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Utils
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void setTitle(final String title) {
|
||||
super.setTitle(title);
|
||||
if (!useAsFrontPage) {
|
||||
headerBinding.channelTitleView.setText(title);
|
||||
}
|
||||
binding.errorContentNotSupported.setVisibility(View.VISIBLE);
|
||||
binding.channelKaomoji.setText("(︶︹︺)");
|
||||
binding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package org.schabi.newpipe.fragments.list.channel;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.PlaylistControlBinding;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo;
|
||||
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
|
||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory;
|
||||
import org.schabi.newpipe.extractor.linkhandler.ReadyChannelTabListLinkHandler;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
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.util.ChannelTabHelper;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.PlayButtonHelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import icepick.State;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
|
||||
public class ChannelTabFragment extends BaseListInfoFragment<InfoItem, ChannelTabInfo>
|
||||
implements PlaylistControlViewHolder {
|
||||
|
||||
// states must be protected and not private for IcePick being able to access them
|
||||
@State
|
||||
protected ListLinkHandler tabHandler;
|
||||
@State
|
||||
protected String channelName;
|
||||
|
||||
private PlaylistControlBinding playlistControlBinding;
|
||||
|
||||
@NonNull
|
||||
public static ChannelTabFragment getInstance(final int serviceId,
|
||||
final ListLinkHandler tabHandler,
|
||||
final String channelName) {
|
||||
final ChannelTabFragment instance = new ChannelTabFragment();
|
||||
instance.serviceId = serviceId;
|
||||
instance.tabHandler = tabHandler;
|
||||
instance.channelName = channelName;
|
||||
return instance;
|
||||
}
|
||||
|
||||
public ChannelTabFragment() {
|
||||
super(UserAction.REQUESTED_CHANNEL);
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// LifeCycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCreate(final Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setHasOptionsMenu(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull final LayoutInflater inflater,
|
||||
@Nullable final ViewGroup container,
|
||||
@Nullable final Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_channel_tab, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
playlistControlBinding = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Supplier<View> getListHeaderSupplier() {
|
||||
if (ChannelTabHelper.isStreamsTab(tabHandler)) {
|
||||
playlistControlBinding = PlaylistControlBinding
|
||||
.inflate(activity.getLayoutInflater(), itemsList, false);
|
||||
return playlistControlBinding::getRoot;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Single<ChannelTabInfo> loadResult(final boolean forceLoad) {
|
||||
return ExtractorHelper.getChannelTab(serviceId, tabHandler, forceLoad);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Single<ListExtractor.InfoItemsPage<InfoItem>> loadMoreItemsLogic() {
|
||||
return ExtractorHelper.getMoreChannelTabItems(serviceId, tabHandler, currentNextPage);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setTitle(final String title) {
|
||||
// The channel name is displayed as title in the toolbar.
|
||||
// The title is always a description of the content of the tab fragment.
|
||||
// It should be unique for each channel because multiple channel tabs
|
||||
// can be added to the main page. Therefore, the channel name is used.
|
||||
// Using the title variable would cause the title to be the same for all channel tabs.
|
||||
super.setTitle(channelName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleResult(@NonNull final ChannelTabInfo result) {
|
||||
super.handleResult(result);
|
||||
|
||||
// FIXME this is a really hacky workaround, to avoid storing useless data in the fragment
|
||||
// state. The problem is, `ReadyChannelTabListLinkHandler` might contain raw JSON data that
|
||||
// uses a lot of memory (e.g. ~800KB for YouTube). While 800KB doesn't seem much, if
|
||||
// you combine just a couple of channel tab fragments you easily go over the 1MB
|
||||
// save&restore transaction limit, and get `TransactionTooLargeException`s. A proper
|
||||
// solution would require rethinking about `ReadyChannelTabListLinkHandler`s.
|
||||
if (tabHandler instanceof ReadyChannelTabListLinkHandler) {
|
||||
try {
|
||||
// once `handleResult` is called, the parsed data was already saved to cache, so
|
||||
// we can discard any raw data in ReadyChannelTabListLinkHandler and create a
|
||||
// link handler with identical properties, but without any raw data
|
||||
final ListLinkHandlerFactory channelTabLHFactory = result.getService()
|
||||
.getChannelTabLHFactory();
|
||||
if (channelTabLHFactory != null) {
|
||||
// some services do not not have a ChannelTabLHFactory
|
||||
tabHandler = channelTabLHFactory.fromQuery(tabHandler.getId(),
|
||||
tabHandler.getContentFilters(), tabHandler.getSortFilter());
|
||||
}
|
||||
} catch (final ParsingException e) {
|
||||
// silently ignore the error, as the app can continue to function normally
|
||||
Log.w(TAG, "Could not recreate channel tab handler", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (playlistControlBinding != null) {
|
||||
// PlaylistControls should be visible only if there is some item in
|
||||
// infoListAdapter other than header
|
||||
if (infoListAdapter.getItemCount() > 1) {
|
||||
playlistControlBinding.getRoot().setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
playlistControlBinding.getRoot().setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
PlayButtonHelper.initPlaylistControlClickListener(
|
||||
activity, playlistControlBinding, this);
|
||||
}
|
||||
}
|
||||
|
||||
public PlayQueue getPlayQueue() {
|
||||
final List<StreamInfoItem> streamItems = infoListAdapter.getItemsList().stream()
|
||||
.filter(StreamInfoItem.class::isInstance)
|
||||
.map(StreamInfoItem.class::cast)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return new ChannelTabPlayQueue(currentInfo.getServiceId(), tabHandler,
|
||||
currentInfo.getNextPage(), streamItems, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
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 org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.CommentRepliesHeaderBinding;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.info_list.ItemViewMode;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.text.TextLinkifier;
|
||||
|
||||
import java.util.Queue;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import icepick.State;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
|
||||
public final class CommentRepliesFragment
|
||||
extends BaseListInfoFragment<CommentsInfoItem, CommentRepliesInfo> {
|
||||
|
||||
public static final String TAG = CommentRepliesFragment.class.getSimpleName();
|
||||
|
||||
@State
|
||||
CommentsInfoItem commentsInfoItem; // the comment to show replies of
|
||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Constructors and lifecycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
// only called by the Android framework, after which readFrom is called and restores all data
|
||||
public CommentRepliesFragment() {
|
||||
super(UserAction.REQUESTED_COMMENT_REPLIES);
|
||||
}
|
||||
|
||||
public CommentRepliesFragment(@NonNull final CommentsInfoItem commentsInfoItem) {
|
||||
this();
|
||||
this.commentsInfoItem = commentsInfoItem;
|
||||
// setting "" as title since the title will be properly set right after
|
||||
setInitialData(commentsInfoItem.getServiceId(), commentsInfoItem.getUrl(), "");
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(@NonNull final LayoutInflater inflater,
|
||||
@Nullable final ViewGroup container,
|
||||
@Nullable final Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.fragment_comments, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
disposables.clear();
|
||||
super.onDestroyView();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Supplier<View> getListHeaderSupplier() {
|
||||
return () -> {
|
||||
final CommentRepliesHeaderBinding binding = CommentRepliesHeaderBinding
|
||||
.inflate(activity.getLayoutInflater(), itemsList, false);
|
||||
final CommentsInfoItem item = commentsInfoItem;
|
||||
|
||||
// load the author avatar
|
||||
PicassoHelper.loadAvatar(item.getUploaderAvatars()).into(binding.authorAvatar);
|
||||
binding.authorAvatar.setVisibility(ImageStrategy.shouldLoadImages()
|
||||
? View.VISIBLE : View.GONE);
|
||||
|
||||
// setup author name and comment date
|
||||
binding.authorName.setText(item.getUploaderName());
|
||||
binding.uploadDate.setText(Localization.relativeTimeOrTextual(
|
||||
getContext(), item.getUploadDate(), item.getTextualUploadDate()));
|
||||
binding.authorTouchArea.setOnClickListener(
|
||||
v -> NavigationHelper.openCommentAuthorIfPresent(requireActivity(), item));
|
||||
|
||||
// setup like count, hearted and pinned
|
||||
binding.thumbsUpCount.setText(
|
||||
Localization.likeCount(requireContext(), item.getLikeCount()));
|
||||
// for heartImage goneMarginEnd was used, but there is no way to tell ConstraintLayout
|
||||
// not to use a different margin only when both the next two views are gone
|
||||
((ConstraintLayout.LayoutParams) binding.thumbsUpCount.getLayoutParams())
|
||||
.setMarginEnd(DeviceUtils.dpToPx(
|
||||
(item.isHeartedByUploader() || item.isPinned() ? 8 : 16),
|
||||
requireContext()));
|
||||
binding.heartImage.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE);
|
||||
binding.pinnedImage.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
|
||||
|
||||
// setup comment content
|
||||
TextLinkifier.fromDescription(binding.commentContent, item.getCommentText(),
|
||||
HtmlCompat.FROM_HTML_MODE_LEGACY, getServiceById(item.getServiceId()),
|
||||
item.getUrl(), disposables, null);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -110,4 +110,14 @@ public class CommentsFragment extends BaseListInfoFragment<CommentsInfoItem, Com
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,13 @@ import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.ServiceList;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.extractor.kiosk.KioskInfo;
|
||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory;
|
||||
import org.schabi.newpipe.extractor.localization.ContentCountry;
|
||||
import org.schabi.newpipe.extractor.services.media_ccc.extractors.MediaCCCLiveStreamKiosk;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
@@ -161,4 +163,14 @@ public class KioskFragment extends BaseListInfoFragment<StreamInfoItem, KioskInf
|
||||
name = kioskTranslatedName;
|
||||
setTitle(kioskTranslatedName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void showEmptyState() {
|
||||
// show "no live streams" for live stream kiosk
|
||||
super.showEmptyState();
|
||||
if (MediaCCCLiveStreamKiosk.KIOSK_ID.equals(currentInfo.getId())
|
||||
&& ServiceList.MediaCCC.getServiceId() == currentInfo.getServiceId()) {
|
||||
setEmptyStateMessage(R.string.no_live_streams);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.schabi.newpipe.fragments.list.playlist;
|
||||
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
|
||||
/**
|
||||
* Interface for {@code R.layout.playlist_control} view holders
|
||||
* to give access to the play queue.
|
||||
*/
|
||||
public interface PlaylistControlViewHolder {
|
||||
PlayQueue getPlayQueue();
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package org.schabi.newpipe.fragments.list.playlist;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
|
||||
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
@@ -37,20 +39,22 @@ import org.schabi.newpipe.extractor.ListExtractor;
|
||||
import org.schabi.newpipe.extractor.ServiceList;
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
|
||||
import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper;
|
||||
import org.schabi.newpipe.extractor.stream.Description;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
|
||||
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
|
||||
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
|
||||
import org.schabi.newpipe.player.PlayerType;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.PlayButtonHelper;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.text.TextEllipsizer;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -64,7 +68,8 @@ import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, PlaylistInfo> {
|
||||
public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, PlaylistInfo>
|
||||
implements PlaylistControlViewHolder {
|
||||
|
||||
private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG";
|
||||
|
||||
@@ -84,6 +89,9 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
|
||||
|
||||
private MenuItem playlistBookmarkButton;
|
||||
|
||||
private long streamCount;
|
||||
private long playlistOverallDurationSeconds;
|
||||
|
||||
public static PlaylistFragment getInstance(final int serviceId, final String url,
|
||||
final String name) {
|
||||
final PlaylistFragment instance = new PlaylistFragment();
|
||||
@@ -233,7 +241,7 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
|
||||
break;
|
||||
case R.id.menu_item_share:
|
||||
ShareUtils.shareText(requireContext(), name, url,
|
||||
currentInfo == null ? null : currentInfo.getThumbnailUrl());
|
||||
currentInfo == null ? List.of() : currentInfo.getThumbnails());
|
||||
break;
|
||||
case R.id.menu_item_bookmark:
|
||||
onBookmarkClicked();
|
||||
@@ -272,6 +280,12 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
|
||||
animate(headerBinding.uploaderLayout, false, 200);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleNextItems(final ListExtractor.InfoItemsPage result) {
|
||||
super.handleNextItems(result);
|
||||
setStreamCountAndOverallDuration(result.getItems(), !result.hasNextPage());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleResult(@NonNull final PlaylistInfo result) {
|
||||
super.handleResult(result);
|
||||
@@ -298,7 +312,6 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
|
||||
|
||||
playlistControlBinding.getRoot().setVisibility(View.VISIBLE);
|
||||
|
||||
final String avatarUrl = result.getUploaderAvatarUrl();
|
||||
if (result.getServiceId() == ServiceList.YouTube.getServiceId()
|
||||
&& (YoutubeParsingHelper.isYoutubeMixId(result.getId())
|
||||
|| YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) {
|
||||
@@ -314,12 +327,35 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
|
||||
R.drawable.ic_radio)
|
||||
);
|
||||
} else {
|
||||
PicassoHelper.loadAvatar(avatarUrl).tag(PICASSO_PLAYLIST_TAG)
|
||||
PicassoHelper.loadAvatar(result.getUploaderAvatars()).tag(PICASSO_PLAYLIST_TAG)
|
||||
.into(headerBinding.uploaderAvatarView);
|
||||
}
|
||||
|
||||
headerBinding.playlistStreamCount.setText(Localization
|
||||
.localizeStreamCount(getContext(), result.getStreamCount()));
|
||||
streamCount = result.getStreamCount();
|
||||
setStreamCountAndOverallDuration(result.getRelatedItems(), !result.hasNextPage());
|
||||
|
||||
final Description description = result.getDescription();
|
||||
if (description != null && description != Description.EMPTY_DESCRIPTION
|
||||
&& !isBlank(description.getContent())) {
|
||||
final TextEllipsizer ellipsizer = new TextEllipsizer(
|
||||
headerBinding.playlistDescription, 5, getServiceById(result.getServiceId()));
|
||||
ellipsizer.setStateChangeListener(isEllipsized ->
|
||||
headerBinding.playlistDescriptionReadMore.setText(
|
||||
Boolean.TRUE.equals(isEllipsized) ? R.string.show_more : R.string.show_less
|
||||
));
|
||||
ellipsizer.setOnContentChanged(canBeEllipsized -> {
|
||||
headerBinding.playlistDescriptionReadMore.setVisibility(
|
||||
Boolean.TRUE.equals(canBeEllipsized) ? View.VISIBLE : View.GONE);
|
||||
if (Boolean.TRUE.equals(canBeEllipsized)) {
|
||||
ellipsizer.ellipsize();
|
||||
}
|
||||
});
|
||||
ellipsizer.setContent(description);
|
||||
headerBinding.playlistDescriptionReadMore.setOnClickListener(v -> ellipsizer.toggle());
|
||||
} else {
|
||||
headerBinding.playlistDescription.setVisibility(View.GONE);
|
||||
headerBinding.playlistDescriptionReadMore.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (!result.getErrors().isEmpty()) {
|
||||
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.REQUESTED_PLAYLIST,
|
||||
@@ -332,25 +368,10 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(getPlaylistBookmarkSubscriber());
|
||||
|
||||
playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view ->
|
||||
NavigationHelper.playOnMainPlayer(activity, getPlayQueue()));
|
||||
playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view ->
|
||||
NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false));
|
||||
playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view ->
|
||||
NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false));
|
||||
|
||||
playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> {
|
||||
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.POPUP);
|
||||
return true;
|
||||
});
|
||||
|
||||
playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> {
|
||||
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.AUDIO);
|
||||
return true;
|
||||
});
|
||||
PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this);
|
||||
}
|
||||
|
||||
private PlayQueue getPlayQueue() {
|
||||
public PlayQueue getPlayQueue() {
|
||||
return getPlayQueue(0);
|
||||
}
|
||||
|
||||
@@ -474,4 +495,20 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
|
||||
playlistBookmarkButton.setIcon(drawable);
|
||||
playlistBookmarkButton.setTitle(titleRes);
|
||||
}
|
||||
|
||||
private void setStreamCountAndOverallDuration(final List<StreamInfoItem> list,
|
||||
final boolean isDurationComplete) {
|
||||
if (activity != null && headerBinding != null) {
|
||||
playlistOverallDurationSeconds += list.stream()
|
||||
.mapToLong(x -> x.getDuration())
|
||||
.sum();
|
||||
headerBinding.playlistStreamCount.setText(
|
||||
Localization.concatenateStrings(
|
||||
Localization.localizeStreamCount(activity, streamCount),
|
||||
Localization.getDurationString(playlistOverallDurationSeconds,
|
||||
isDurationComplete, true))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,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.util.ExtractorHelper.showMetaInfoInTextView;
|
||||
import static java.util.Arrays.asList;
|
||||
@@ -167,6 +168,10 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
|
||||
/*////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
/**
|
||||
* TextWatcher to remove rich-text formatting on the search EditText when pasting content
|
||||
* from the clipboard.
|
||||
*/
|
||||
private TextWatcher textWatcher;
|
||||
|
||||
public static SearchFragment getInstance(final int serviceId, final String searchString) {
|
||||
@@ -385,7 +390,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
@Override
|
||||
public void onSaveInstanceState(@NonNull final Bundle bundle) {
|
||||
searchString = searchEditText != null
|
||||
? searchEditText.getText().toString()
|
||||
? getSearchEditString().trim()
|
||||
: searchString;
|
||||
super.onSaveInstanceState(bundle);
|
||||
}
|
||||
@@ -396,11 +401,11 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
|
||||
@Override
|
||||
public void reloadContent() {
|
||||
if (!TextUtils.isEmpty(searchString)
|
||||
|| (searchEditText != null && !TextUtils.isEmpty(searchEditText.getText()))) {
|
||||
if (!TextUtils.isEmpty(searchString) || (searchEditText != null
|
||||
&& !isSearchEditBlank())) {
|
||||
search(!TextUtils.isEmpty(searchString)
|
||||
? searchString
|
||||
: searchEditText.getText().toString(), this.contentFilter, "");
|
||||
: getSearchEditString(), this.contentFilter, "");
|
||||
} else {
|
||||
if (searchEditText != null) {
|
||||
searchEditText.setText("");
|
||||
@@ -494,7 +499,8 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
}
|
||||
searchEditText.setText(searchString);
|
||||
|
||||
if (TextUtils.isEmpty(searchString) || TextUtils.isEmpty(searchEditText.getText())) {
|
||||
if (TextUtils.isEmpty(searchString)
|
||||
|| isSearchEditBlank()) {
|
||||
searchToolbarContainer.setTranslationX(100);
|
||||
searchToolbarContainer.setAlpha(0.0f);
|
||||
searchToolbarContainer.setVisibility(View.VISIBLE);
|
||||
@@ -518,7 +524,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onClick() called with: v = [" + v + "]");
|
||||
}
|
||||
if (TextUtils.isEmpty(searchEditText.getText())) {
|
||||
if (isSearchEditBlank()) {
|
||||
NavigationHelper.gotoMainFragment(getFM());
|
||||
return;
|
||||
}
|
||||
@@ -583,11 +589,13 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
@Override
|
||||
public void beforeTextChanged(final CharSequence s, final int start,
|
||||
final int count, final int after) {
|
||||
// Do nothing, old text is already clean
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(final CharSequence s, final int start,
|
||||
final int before, final int count) {
|
||||
// Changes are handled in afterTextChanged; CharSequence cannot be changed here.
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -597,7 +605,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
s.removeSpan(span);
|
||||
}
|
||||
|
||||
final String newText = searchEditText.getText().toString();
|
||||
final String newText = getSearchEditString().trim();
|
||||
suggestionPublisher.onNext(newText);
|
||||
}
|
||||
};
|
||||
@@ -613,7 +621,8 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
} else if (event != null
|
||||
&& (event.getKeyCode() == KeyEvent.KEYCODE_ENTER
|
||||
|| event.getAction() == EditorInfo.IME_ACTION_SEARCH)) {
|
||||
search(searchEditText.getText().toString(), new String[0], "");
|
||||
searchEditText.setText(getSearchEditString().trim());
|
||||
search(getSearchEditString(), new String[0], "");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -688,7 +697,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
howManyDeleted -> suggestionPublisher
|
||||
.onNext(searchEditText.getText().toString()),
|
||||
.onNext(getSearchEditString()),
|
||||
throwable -> showSnackBarError(new ErrorInfo(throwable,
|
||||
UserAction.DELETE_FROM_HISTORY,
|
||||
"Deleting item failed")));
|
||||
@@ -717,9 +726,9 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
.getRelatedSearches(query, similarQueryLimit, 25)
|
||||
.toObservable()
|
||||
.map(searchHistoryEntries ->
|
||||
searchHistoryEntries.stream()
|
||||
.map(entry -> new SuggestionItem(true, entry))
|
||||
.collect(Collectors.toList()));
|
||||
searchHistoryEntries.stream()
|
||||
.map(entry -> new SuggestionItem(true, entry))
|
||||
.collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
private Observable<List<SuggestionItem>> getRemoteSuggestionsObservable(final String query) {
|
||||
@@ -786,12 +795,12 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
} else if (listNotification.isOnError()
|
||||
&& listNotification.getError() != null
|
||||
&& !ExceptionUtils.isInterruptedCaused(
|
||||
listNotification.getError())) {
|
||||
listNotification.getError())) {
|
||||
showSnackBarError(new ErrorInfo(listNotification.getError(),
|
||||
UserAction.GET_SUGGESTIONS, searchString, serviceId));
|
||||
}
|
||||
}, throwable -> showSnackBarError(new ErrorInfo(
|
||||
throwable, UserAction.GET_SUGGESTIONS, searchString, serviceId)));
|
||||
throwable, UserAction.GET_SUGGESTIONS, searchString, serviceId)));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -799,7 +808,13 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
// no-op
|
||||
}
|
||||
|
||||
private void search(final String theSearchString,
|
||||
/**
|
||||
* Perform a search.
|
||||
* @param theSearchString the trimmed search string
|
||||
* @param theContentFilter the content filter to use. FIXME: unused param
|
||||
* @param theSortFilter FIXME: unused param
|
||||
*/
|
||||
private void search(@NonNull final String theSearchString,
|
||||
final String[] theContentFilter,
|
||||
final String theSortFilter) {
|
||||
if (DEBUG) {
|
||||
@@ -809,25 +824,26 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if theSearchString is a URL which can be opened by NewPipe directly
|
||||
// and open it if possible.
|
||||
try {
|
||||
final StreamingService streamingService = NewPipe.getServiceByUrl(theSearchString);
|
||||
if (streamingService != null) {
|
||||
showLoading();
|
||||
disposables.add(Observable
|
||||
.fromCallable(() -> NavigationHelper.getIntentByLink(activity,
|
||||
streamingService, theSearchString))
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(intent -> {
|
||||
getFM().popBackStackImmediate();
|
||||
activity.startActivity(intent);
|
||||
}, throwable -> showTextError(getString(R.string.unsupported_url))));
|
||||
return;
|
||||
}
|
||||
showLoading();
|
||||
disposables.add(Observable
|
||||
.fromCallable(() -> NavigationHelper.getIntentByLink(activity,
|
||||
streamingService, theSearchString))
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(intent -> {
|
||||
getFM().popBackStackImmediate();
|
||||
activity.startActivity(intent);
|
||||
}, throwable -> showTextError(getString(R.string.unsupported_url))));
|
||||
return;
|
||||
} catch (final Exception ignored) {
|
||||
// Exception occurred, it's not a url
|
||||
}
|
||||
|
||||
// prepare search
|
||||
lastSearchedString = this.searchString;
|
||||
this.searchString = theSearchString;
|
||||
infoListAdapter.clearStreamItemList();
|
||||
@@ -836,13 +852,17 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
searchBinding.searchMetaInfoSeparator, disposables);
|
||||
hideKeyboardSearch();
|
||||
|
||||
// store search query if search history is enabled
|
||||
disposables.add(historyRecordManager.onSearched(serviceId, theSearchString)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
ignored -> { },
|
||||
ignored -> {
|
||||
},
|
||||
throwable -> showSnackBarError(new ErrorInfo(throwable, UserAction.SEARCHED,
|
||||
theSearchString, serviceId))
|
||||
));
|
||||
|
||||
// load search results
|
||||
suggestionPublisher.onNext(theSearchString);
|
||||
startLoading(false);
|
||||
}
|
||||
@@ -932,6 +952,14 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
sortFilter = theSortFilter;
|
||||
}
|
||||
|
||||
private String getSearchEditString() {
|
||||
return searchEditText.getText().toString();
|
||||
}
|
||||
|
||||
private boolean isSearchEditBlank() {
|
||||
return isBlank(getSearchEditString());
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Suggestion Results
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@@ -973,6 +1001,9 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
}
|
||||
|
||||
searchSuggestion = result.getSearchSuggestion();
|
||||
if (searchSuggestion != null) {
|
||||
searchSuggestion = searchSuggestion.trim();
|
||||
}
|
||||
isCorrectedSearch = result.isCorrectedSearch();
|
||||
|
||||
// List<MetaInfo> cannot be bundled without creating some containers
|
||||
@@ -1074,7 +1105,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
howManyDeleted -> suggestionPublisher
|
||||
.onNext(searchEditText.getText().toString()),
|
||||
.onNext(getSearchEditString()),
|
||||
throwable -> showSnackBarError(new ErrorInfo(throwable,
|
||||
UserAction.DELETE_FROM_HISTORY, "Deleting item failed")));
|
||||
disposables.add(onDelete);
|
||||
|
||||
@@ -21,18 +21,17 @@ import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
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.RelatedItemInfo;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
|
||||
public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, RelatedItemInfo>
|
||||
public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, RelatedItemsInfo>
|
||||
implements SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
private static final String INFO_KEY = "related_info_key";
|
||||
|
||||
private RelatedItemInfo relatedItemInfo;
|
||||
private RelatedItemsInfo relatedItemsInfo;
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Views
|
||||
@@ -69,7 +68,7 @@ public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, Related
|
||||
|
||||
@Override
|
||||
protected Supplier<View> getListHeaderSupplier() {
|
||||
if (relatedItemInfo == null || relatedItemInfo.getRelatedItems() == null) {
|
||||
if (relatedItemsInfo == null || relatedItemsInfo.getRelatedItems() == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -97,8 +96,8 @@ public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, Related
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
protected Single<RelatedItemInfo> loadResult(final boolean forceLoad) {
|
||||
return Single.fromCallable(() -> relatedItemInfo);
|
||||
protected Single<RelatedItemsInfo> loadResult(final boolean forceLoad) {
|
||||
return Single.fromCallable(() -> relatedItemsInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -110,7 +109,7 @@ public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, Related
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleResult(@NonNull final RelatedItemInfo result) {
|
||||
public void handleResult(@NonNull final RelatedItemsInfo result) {
|
||||
super.handleResult(result);
|
||||
|
||||
if (headerBinding != null) {
|
||||
@@ -137,23 +136,23 @@ public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, Related
|
||||
|
||||
private void setInitialData(final StreamInfo info) {
|
||||
super.setInitialData(info.getServiceId(), info.getUrl(), info.getName());
|
||||
if (this.relatedItemInfo == null) {
|
||||
this.relatedItemInfo = RelatedItemInfo.getInfo(info);
|
||||
if (this.relatedItemsInfo == null) {
|
||||
this.relatedItemsInfo = new RelatedItemsInfo(info);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(@NonNull final Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
outState.putSerializable(INFO_KEY, relatedItemInfo);
|
||||
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 RelatedItemInfo) {
|
||||
this.relatedItemInfo = (RelatedItemInfo) serializable;
|
||||
if (serializable instanceof RelatedItemsInfo) {
|
||||
this.relatedItemsInfo = (RelatedItemsInfo) serializable;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.schabi.newpipe.fragments.list.videos;
|
||||
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.ListInfo;
|
||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
|
||||
public final class RelatedItemsInfo extends ListInfo<InfoItem> {
|
||||
/**
|
||||
* This class is used to wrap the related items of a StreamInfo into a ListInfo object.
|
||||
*
|
||||
* @param info the stream info from which to get related items
|
||||
*/
|
||||
public RelatedItemsInfo(final StreamInfo info) {
|
||||
super(info.getServiceId(), new ListLinkHandler(info.getOriginalUrl(), info.getUrl(),
|
||||
info.getId(), Collections.emptyList(), null), info.getName());
|
||||
setRelatedItems(new ArrayList<>(info.getRelatedItems()));
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,7 @@ import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.CommentsInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.CommentsMiniInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.CommentInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.InfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder;
|
||||
@@ -87,8 +86,7 @@ public class InfoItemBuilder {
|
||||
return useMiniVariant ? new PlaylistMiniInfoItemHolder(this, parent)
|
||||
: new PlaylistInfoItemHolder(this, parent);
|
||||
case COMMENT:
|
||||
return useMiniVariant ? new CommentsMiniInfoItemHolder(this, parent)
|
||||
: new CommentsInfoItemHolder(this, parent);
|
||||
return new CommentInfoItemHolder(this, parent);
|
||||
default:
|
||||
throw new RuntimeException("InfoType not expected = " + infoType.name());
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ 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.CommentsInfoItemHolder;
|
||||
import org.schabi.newpipe.info_list.holder.CommentsMiniInfoItemHolder;
|
||||
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;
|
||||
@@ -79,8 +78,7 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
||||
private static final int PLAYLIST_HOLDER_TYPE = 0x301;
|
||||
private static final int GRID_PLAYLIST_HOLDER_TYPE = 0x302;
|
||||
private static final int CARD_PLAYLIST_HOLDER_TYPE = 0x303;
|
||||
private static final int MINI_COMMENT_HOLDER_TYPE = 0x400;
|
||||
private static final int COMMENT_HOLDER_TYPE = 0x401;
|
||||
private static final int COMMENT_HOLDER_TYPE = 0x400;
|
||||
|
||||
private final LayoutInflater layoutInflater;
|
||||
private final InfoItemBuilder infoItemBuilder;
|
||||
@@ -271,7 +269,7 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
||||
return PLAYLIST_HOLDER_TYPE;
|
||||
}
|
||||
case COMMENT:
|
||||
return useMiniVariant ? MINI_COMMENT_HOLDER_TYPE : COMMENT_HOLDER_TYPE;
|
||||
return COMMENT_HOLDER_TYPE;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
@@ -320,10 +318,8 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
||||
return new PlaylistGridInfoItemHolder(infoItemBuilder, parent);
|
||||
case CARD_PLAYLIST_HOLDER_TYPE:
|
||||
return new PlaylistCardInfoItemHolder(infoItemBuilder, parent);
|
||||
case MINI_COMMENT_HOLDER_TYPE:
|
||||
return new CommentsMiniInfoItemHolder(infoItemBuilder, parent);
|
||||
case COMMENT_HOLDER_TYPE:
|
||||
return new CommentsInfoItemHolder(infoItemBuilder, parent);
|
||||
return new CommentInfoItemHolder(infoItemBuilder, parent);
|
||||
default:
|
||||
return new FallbackViewHolder(new View(parent.getContext()));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import com.xwray.groupie.Item
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.stream.StreamSegment
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.PicassoHelper
|
||||
import org.schabi.newpipe.util.image.PicassoHelper
|
||||
|
||||
class StreamSegmentItem(
|
||||
private val item: StreamSegment,
|
||||
|
||||
@@ -104,7 +104,7 @@ public enum StreamDialogDefaultEntry {
|
||||
|
||||
SHARE(R.string.share, (fragment, item) ->
|
||||
ShareUtils.shareText(fragment.requireContext(), item.getName(), item.getUrl(),
|
||||
item.getThumbnailUrl())),
|
||||
item.getThumbnails())),
|
||||
|
||||
/**
|
||||
* Opens a {@link DownloadDialog} after fetching some stream info.
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
||||
import org.schabi.newpipe.extractor.utils.Utils;
|
||||
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
|
||||
public class ChannelMiniInfoItemHolder extends InfoItemHolder {
|
||||
@@ -56,7 +56,7 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder {
|
||||
itemAdditionalDetailView.setText(getDetailLine(item));
|
||||
}
|
||||
|
||||
PicassoHelper.loadAvatar(item.getThumbnailUrl()).into(itemThumbnailView);
|
||||
PicassoHelper.loadAvatar(item.getThumbnails()).into(itemThumbnailView);
|
||||
|
||||
itemView.setOnClickListener(view -> {
|
||||
if (itemBuilder.getOnChannelSelectedListener() != null) {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package org.schabi.newpipe.info_list.holder;
|
||||
|
||||
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
|
||||
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.text.style.URLSpan;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.text.CommentTextOnTouchListener;
|
||||
import org.schabi.newpipe.util.text.TextEllipsizer;
|
||||
|
||||
public class CommentInfoItemHolder extends InfoItemHolder {
|
||||
|
||||
private static final int COMMENT_DEFAULT_LINES = 2;
|
||||
private final int commentHorizontalPadding;
|
||||
private final int commentVerticalPadding;
|
||||
|
||||
private final RelativeLayout itemRoot;
|
||||
private final ImageView itemThumbnailView;
|
||||
private final TextView itemContentView;
|
||||
private final ImageView itemThumbsUpView;
|
||||
private final TextView itemLikesCountView;
|
||||
private final TextView itemTitleView;
|
||||
private final ImageView itemHeartView;
|
||||
private final ImageView itemPinnedView;
|
||||
private final Button repliesButton;
|
||||
|
||||
@NonNull
|
||||
private final TextEllipsizer textEllipsizer;
|
||||
|
||||
public CommentInfoItemHolder(final InfoItemBuilder infoItemBuilder,
|
||||
final ViewGroup parent) {
|
||||
super(infoItemBuilder, R.layout.list_comment_item, parent);
|
||||
|
||||
itemRoot = itemView.findViewById(R.id.itemRoot);
|
||||
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
|
||||
itemContentView = itemView.findViewById(R.id.itemCommentContentView);
|
||||
itemThumbsUpView = itemView.findViewById(R.id.detail_thumbs_up_img_view);
|
||||
itemLikesCountView = itemView.findViewById(R.id.detail_thumbs_up_count_view);
|
||||
itemTitleView = itemView.findViewById(R.id.itemTitleView);
|
||||
itemHeartView = itemView.findViewById(R.id.detail_heart_image_view);
|
||||
itemPinnedView = itemView.findViewById(R.id.detail_pinned_view);
|
||||
repliesButton = itemView.findViewById(R.id.replies_button);
|
||||
|
||||
commentHorizontalPadding = (int) infoItemBuilder.getContext()
|
||||
.getResources().getDimension(R.dimen.comments_horizontal_padding);
|
||||
commentVerticalPadding = (int) infoItemBuilder.getContext()
|
||||
.getResources().getDimension(R.dimen.comments_vertical_padding);
|
||||
|
||||
textEllipsizer = new TextEllipsizer(itemContentView, COMMENT_DEFAULT_LINES, null);
|
||||
textEllipsizer.setStateChangeListener(isEllipsized -> {
|
||||
if (Boolean.TRUE.equals(isEllipsized)) {
|
||||
denyLinkFocus();
|
||||
} else {
|
||||
determineMovementMethod();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateFromItem(final InfoItem infoItem,
|
||||
final HistoryRecordManager historyRecordManager) {
|
||||
if (!(infoItem instanceof CommentsInfoItem)) {
|
||||
return;
|
||||
}
|
||||
final CommentsInfoItem item = (CommentsInfoItem) infoItem;
|
||||
|
||||
|
||||
// load the author avatar
|
||||
PicassoHelper.loadAvatar(item.getUploaderAvatars()).into(itemThumbnailView);
|
||||
if (ImageStrategy.shouldLoadImages()) {
|
||||
itemThumbnailView.setVisibility(View.VISIBLE);
|
||||
itemRoot.setPadding(commentVerticalPadding, commentVerticalPadding,
|
||||
commentVerticalPadding, commentVerticalPadding);
|
||||
} else {
|
||||
itemThumbnailView.setVisibility(View.GONE);
|
||||
itemRoot.setPadding(commentHorizontalPadding, commentVerticalPadding,
|
||||
commentHorizontalPadding, commentVerticalPadding);
|
||||
}
|
||||
itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item));
|
||||
|
||||
|
||||
// setup the top row, with pinned icon, author name and comment date
|
||||
itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
|
||||
itemTitleView.setText(Localization.concatenateStrings(item.getUploaderName(),
|
||||
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(CommentTextOnTouchListener.INSTANCE);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package org.schabi.newpipe.info_list.holder;
|
||||
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
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;
|
||||
|
||||
/*
|
||||
* Created by Christian Schabesberger on 12.02.17.
|
||||
*
|
||||
* Copyright (C) Christian Schabesberger 2016 <chris.schabesberger@mailbox.org>
|
||||
* ChannelInfoItemHolder .java is part of NewPipe.
|
||||
*
|
||||
* NewPipe is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* NewPipe is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
public class CommentsInfoItemHolder extends CommentsMiniInfoItemHolder {
|
||||
public final TextView itemTitleView;
|
||||
private final ImageView itemHeartView;
|
||||
private final ImageView itemPinnedView;
|
||||
|
||||
public CommentsInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) {
|
||||
super(infoItemBuilder, R.layout.list_comments_item, parent);
|
||||
|
||||
itemTitleView = itemView.findViewById(R.id.itemTitleView);
|
||||
itemHeartView = itemView.findViewById(R.id.detail_heart_image_view);
|
||||
itemPinnedView = itemView.findViewById(R.id.detail_pinned_view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateFromItem(final InfoItem infoItem,
|
||||
final HistoryRecordManager historyRecordManager) {
|
||||
super.updateFromItem(infoItem, historyRecordManager);
|
||||
|
||||
if (!(infoItem instanceof CommentsInfoItem)) {
|
||||
return;
|
||||
}
|
||||
final CommentsInfoItem item = (CommentsInfoItem) infoItem;
|
||||
|
||||
itemTitleView.setText(item.getUploaderName());
|
||||
|
||||
itemHeartView.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE);
|
||||
|
||||
itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
package org.schabi.newpipe.info_list.holder;
|
||||
|
||||
import static android.text.TextUtils.isEmpty;
|
||||
|
||||
import android.graphics.Paint;
|
||||
import android.text.Layout;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.text.style.URLSpan;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.RelativeLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.text.HtmlCompat;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.ServiceList;
|
||||
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.stream.Description;
|
||||
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.PicassoHelper;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.util.text.CommentTextOnTouchListener;
|
||||
import org.schabi.newpipe.util.text.TextLinkifier;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
|
||||
public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
||||
private static final String TAG = "CommentsMiniIIHolder";
|
||||
private static final String ELLIPSIS = "…";
|
||||
|
||||
private static final int COMMENT_DEFAULT_LINES = 2;
|
||||
private static final int COMMENT_EXPANDED_LINES = 1000;
|
||||
|
||||
private final int commentHorizontalPadding;
|
||||
private final int commentVerticalPadding;
|
||||
|
||||
private final Paint paintAtContentSize;
|
||||
private final float ellipsisWidthPx;
|
||||
|
||||
private final RelativeLayout itemRoot;
|
||||
private final ImageView itemThumbnailView;
|
||||
private final TextView itemContentView;
|
||||
private final TextView itemLikesCountView;
|
||||
private final TextView itemPublishedTime;
|
||||
|
||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||
@Nullable private Description commentText;
|
||||
@Nullable private StreamingService streamService;
|
||||
@Nullable private String streamUrl;
|
||||
|
||||
CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId,
|
||||
final ViewGroup parent) {
|
||||
super(infoItemBuilder, layoutId, parent);
|
||||
|
||||
itemRoot = itemView.findViewById(R.id.itemRoot);
|
||||
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
|
||||
itemLikesCountView = itemView.findViewById(R.id.detail_thumbs_up_count_view);
|
||||
itemPublishedTime = itemView.findViewById(R.id.itemPublishedTime);
|
||||
itemContentView = itemView.findViewById(R.id.itemCommentContentView);
|
||||
|
||||
commentHorizontalPadding = (int) infoItemBuilder.getContext()
|
||||
.getResources().getDimension(R.dimen.comments_horizontal_padding);
|
||||
commentVerticalPadding = (int) infoItemBuilder.getContext()
|
||||
.getResources().getDimension(R.dimen.comments_vertical_padding);
|
||||
|
||||
paintAtContentSize = new Paint();
|
||||
paintAtContentSize.setTextSize(itemContentView.getTextSize());
|
||||
ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS);
|
||||
}
|
||||
|
||||
public CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder,
|
||||
final ViewGroup parent) {
|
||||
this(infoItemBuilder, R.layout.list_comments_mini_item, parent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateFromItem(final InfoItem infoItem,
|
||||
final HistoryRecordManager historyRecordManager) {
|
||||
if (!(infoItem instanceof CommentsInfoItem)) {
|
||||
return;
|
||||
}
|
||||
final CommentsInfoItem item = (CommentsInfoItem) infoItem;
|
||||
|
||||
PicassoHelper.loadAvatar(item.getUploaderAvatarUrl()).into(itemThumbnailView);
|
||||
if (PicassoHelper.getShouldLoadImages()) {
|
||||
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));
|
||||
|
||||
try {
|
||||
streamService = NewPipe.getService(item.getServiceId());
|
||||
} catch (final ExtractionException e) {
|
||||
// should never happen
|
||||
ErrorUtil.showUiErrorSnackbar(itemBuilder.getContext(), "Getting StreamingService", e);
|
||||
Log.w(TAG, "Cannot obtain service from comment service id, defaulting to YouTube", e);
|
||||
streamService = ServiceList.YouTube;
|
||||
}
|
||||
streamUrl = item.getUrl();
|
||||
commentText = item.getCommentText();
|
||||
ellipsize();
|
||||
|
||||
//noinspection ClickableViewAccessibility
|
||||
itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE);
|
||||
|
||||
if (item.getLikeCount() >= 0) {
|
||||
itemLikesCountView.setText(
|
||||
Localization.shortCount(
|
||||
itemBuilder.getContext(),
|
||||
item.getLikeCount()));
|
||||
} else {
|
||||
itemLikesCountView.setText("-");
|
||||
}
|
||||
|
||||
if (item.getUploadDate() != null) {
|
||||
itemPublishedTime.setText(Localization.relativeTime(item.getUploadDate()
|
||||
.offsetDateTime()));
|
||||
} else {
|
||||
itemPublishedTime.setText(item.getTextualUploadDate());
|
||||
}
|
||||
|
||||
itemView.setOnClickListener(view -> {
|
||||
toggleEllipsize();
|
||||
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(final CommentsInfoItem item) {
|
||||
if (isEmpty(item.getUploaderUrl())) {
|
||||
return;
|
||||
}
|
||||
final AppCompatActivity activity = (AppCompatActivity) itemBuilder.getContext();
|
||||
try {
|
||||
NavigationHelper.openChannelFragment(
|
||||
activity.getSupportFragmentManager(),
|
||||
item.getServiceId(),
|
||||
item.getUploaderUrl(),
|
||||
item.getUploaderName());
|
||||
} catch (final Exception e) {
|
||||
ErrorUtil.showUiErrorSnackbar(activity, "Opening channel fragment", e);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private void ellipsize() {
|
||||
itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
|
||||
linkifyCommentContentView(v -> {
|
||||
boolean hasEllipsis = false;
|
||||
|
||||
final CharSequence charSeqText = itemContentView.getText();
|
||||
if (charSeqText != null && itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
|
||||
// Note that converting to String removes spans (i.e. links), but that's something
|
||||
// we actually want since when the text is ellipsized we want all clicks on the
|
||||
// comment to expand the comment, not to open links.
|
||||
final String text = charSeqText.toString();
|
||||
|
||||
final Layout layout = itemContentView.getLayout();
|
||||
final float lineWidth = layout.getLineWidth(COMMENT_DEFAULT_LINES - 1);
|
||||
final float layoutWidth = layout.getWidth();
|
||||
final int lineStart = layout.getLineStart(COMMENT_DEFAULT_LINES - 1);
|
||||
final int lineEnd = layout.getLineEnd(COMMENT_DEFAULT_LINES - 1);
|
||||
|
||||
// remove characters up until there is enough space for the ellipsis
|
||||
// (also summing 2 more pixels, just to be sure to avoid float rounding errors)
|
||||
int end = lineEnd;
|
||||
float removedCharactersWidth = 0.0f;
|
||||
while (lineWidth - removedCharactersWidth + ellipsisWidthPx + 2.0f > layoutWidth
|
||||
&& end >= lineStart) {
|
||||
end -= 1;
|
||||
// recalculate each time to account for ligatures or other similar things
|
||||
removedCharactersWidth = paintAtContentSize.measureText(
|
||||
text.substring(end, lineEnd));
|
||||
}
|
||||
|
||||
// remove trailing spaces and newlines
|
||||
while (end > 0 && Character.isWhitespace(text.charAt(end - 1))) {
|
||||
end -= 1;
|
||||
}
|
||||
|
||||
final String newVal = text.substring(0, end) + ELLIPSIS;
|
||||
itemContentView.setText(newVal);
|
||||
hasEllipsis = true;
|
||||
}
|
||||
|
||||
itemContentView.setMaxLines(COMMENT_DEFAULT_LINES);
|
||||
if (hasEllipsis) {
|
||||
denyLinkFocus();
|
||||
} else {
|
||||
determineMovementMethod();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void toggleEllipsize() {
|
||||
final CharSequence text = itemContentView.getText();
|
||||
if (!isEmpty(text) && text.charAt(text.length() - 1) == ELLIPSIS.charAt(0)) {
|
||||
expand();
|
||||
} else if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
|
||||
ellipsize();
|
||||
}
|
||||
}
|
||||
|
||||
private void expand() {
|
||||
itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
|
||||
linkifyCommentContentView(v -> determineMovementMethod());
|
||||
}
|
||||
|
||||
private void linkifyCommentContentView(@Nullable final Consumer<TextView> onCompletion) {
|
||||
disposables.clear();
|
||||
if (commentText != null) {
|
||||
TextLinkifier.fromDescription(itemContentView, commentText,
|
||||
HtmlCompat.FROM_HTML_MODE_LEGACY, streamService, streamUrl, disposables,
|
||||
onCompletion);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
|
||||
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
|
||||
public class PlaylistMiniInfoItemHolder extends InfoItemHolder {
|
||||
@@ -46,7 +46,7 @@ public class PlaylistMiniInfoItemHolder extends InfoItemHolder {
|
||||
.localizeStreamCountMini(itemStreamCountView.getContext(), item.getStreamCount()));
|
||||
itemUploaderView.setText(item.getUploaderName());
|
||||
|
||||
PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView);
|
||||
PicassoHelper.loadPlaylistThumbnail(item.getThumbnails()).into(itemThumbnailView);
|
||||
|
||||
itemView.setOnClickListener(view -> {
|
||||
if (itemBuilder.getOnPlaylistSelectedListener() != null) {
|
||||
|
||||
@@ -12,10 +12,6 @@ import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
|
||||
/*
|
||||
* Created by Christian Schabesberger on 01.08.16.
|
||||
* <p>
|
||||
@@ -81,7 +77,9 @@ public class StreamInfoItemHolder extends StreamMiniInfoItemHolder {
|
||||
}
|
||||
}
|
||||
|
||||
final String uploadDate = getFormattedRelativeUploadDate(infoItem);
|
||||
final String uploadDate = Localization.relativeTimeOrTextual(itemBuilder.getContext(),
|
||||
infoItem.getUploadDate(),
|
||||
infoItem.getTextualUploadDate());
|
||||
if (!TextUtils.isEmpty(uploadDate)) {
|
||||
if (viewsAndDate.isEmpty()) {
|
||||
return uploadDate;
|
||||
@@ -92,20 +90,4 @@ public class StreamInfoItemHolder extends StreamMiniInfoItemHolder {
|
||||
|
||||
return viewsAndDate;
|
||||
}
|
||||
|
||||
private String getFormattedRelativeUploadDate(final StreamInfoItem infoItem) {
|
||||
if (infoItem.getUploadDate() != null) {
|
||||
String formattedRelativeTime = Localization
|
||||
.relativeTime(infoItem.getUploadDate().offsetDateTime());
|
||||
|
||||
if (DEBUG && PreferenceManager.getDefaultSharedPreferences(itemBuilder.getContext())
|
||||
.getBoolean(itemBuilder.getContext()
|
||||
.getString(R.string.show_original_time_ago_key), false)) {
|
||||
formattedRelativeTime += " (" + infoItem.getTextualUploadDate() + ")";
|
||||
}
|
||||
return formattedRelativeTime;
|
||||
} else {
|
||||
return infoItem.getTextualUploadDate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import org.schabi.newpipe.ktx.ViewUtils;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.DependentPreferenceHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.StreamTypeUtil;
|
||||
import org.schabi.newpipe.views.AnimatedProgressBar;
|
||||
|
||||
@@ -87,7 +87,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
||||
}
|
||||
|
||||
// Default thumbnail is shown on error, while loading and if the url is empty
|
||||
PicassoHelper.loadThumbnail(item.getThumbnailUrl()).into(itemThumbnailView);
|
||||
PicassoHelper.loadThumbnail(item.getThumbnails()).into(itemThumbnailView);
|
||||
|
||||
itemView.setOnClickListener(view -> {
|
||||
if (itemBuilder.getOnStreamSelectedListener() != null) {
|
||||
|
||||
9
app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt
Normal file
9
app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt
Normal file
@@ -0,0 +1,9 @@
|
||||
package org.schabi.newpipe.ktx
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import androidx.core.os.BundleCompat
|
||||
|
||||
inline fun <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? {
|
||||
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import org.schabi.newpipe.database.LocalItem;
|
||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity;
|
||||
import org.schabi.newpipe.info_list.ItemViewMode;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder;
|
||||
import org.schabi.newpipe.local.holder.LocalItemHolder;
|
||||
import org.schabi.newpipe.local.holder.LocalPlaylistCardItemHolder;
|
||||
import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder;
|
||||
@@ -24,6 +25,7 @@ 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;
|
||||
import org.schabi.newpipe.local.holder.RemotePlaylistItemHolder;
|
||||
@@ -73,10 +75,12 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||
private static final int LOCAL_PLAYLIST_HOLDER_TYPE = 0x2000;
|
||||
private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2001;
|
||||
private static final int LOCAL_PLAYLIST_CARD_HOLDER_TYPE = 0x2002;
|
||||
private static final int LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE = 0x2003;
|
||||
|
||||
private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x3000;
|
||||
private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x3001;
|
||||
private static final int REMOTE_PLAYLIST_CARD_HOLDER_TYPE = 0x3002;
|
||||
private static final int REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE = 0x3003;
|
||||
|
||||
private final LocalItemBuilder localItemBuilder;
|
||||
private final ArrayList<LocalItem> localItems;
|
||||
@@ -87,6 +91,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||
private View header = null;
|
||||
private View footer = null;
|
||||
private ItemViewMode itemViewMode = ItemViewMode.LIST;
|
||||
private boolean useItemHandle = false;
|
||||
|
||||
public LocalItemListAdapter(final Context context) {
|
||||
recordManager = new HistoryRecordManager(context);
|
||||
@@ -180,6 +185,10 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||
this.itemViewMode = itemViewMode;
|
||||
}
|
||||
|
||||
public void setUseItemHandle(final boolean useItemHandle) {
|
||||
this.useItemHandle = useItemHandle;
|
||||
}
|
||||
|
||||
public void setHeader(final View header) {
|
||||
final boolean changed = header != this.header;
|
||||
this.header = header;
|
||||
@@ -257,7 +266,9 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||
final LocalItem item = localItems.get(position);
|
||||
switch (item.getLocalItemType()) {
|
||||
case PLAYLIST_LOCAL_ITEM:
|
||||
if (itemViewMode == ItemViewMode.CARD) {
|
||||
if (useItemHandle) {
|
||||
return LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE;
|
||||
} else if (itemViewMode == ItemViewMode.CARD) {
|
||||
return LOCAL_PLAYLIST_CARD_HOLDER_TYPE;
|
||||
} else if (itemViewMode == ItemViewMode.GRID) {
|
||||
return LOCAL_PLAYLIST_GRID_HOLDER_TYPE;
|
||||
@@ -265,7 +276,9 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||
return LOCAL_PLAYLIST_HOLDER_TYPE;
|
||||
}
|
||||
case PLAYLIST_REMOTE_ITEM:
|
||||
if (itemViewMode == ItemViewMode.CARD) {
|
||||
if (useItemHandle) {
|
||||
return REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE;
|
||||
} else if (itemViewMode == ItemViewMode.CARD) {
|
||||
return REMOTE_PLAYLIST_CARD_HOLDER_TYPE;
|
||||
} else if (itemViewMode == ItemViewMode.GRID) {
|
||||
return REMOTE_PLAYLIST_GRID_HOLDER_TYPE;
|
||||
@@ -314,12 +327,16 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
||||
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:
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package org.schabi.newpipe.local.bookmark;
|
||||
|
||||
import static org.schabi.newpipe.local.bookmark.MergedPlaylistManager.getMergedOrderedPlaylists;
|
||||
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Bundle;
|
||||
import android.os.Parcelable;
|
||||
import android.text.InputType;
|
||||
import android.util.Log;
|
||||
import android.util.Pair;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
@@ -13,6 +16,8 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.reactivestreams.Subscriber;
|
||||
import org.reactivestreams.Subscription;
|
||||
@@ -27,29 +32,45 @@ import org.schabi.newpipe.databinding.DialogEditTextBinding;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.local.BaseLocalListFragment;
|
||||
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.util.debounce.DebounceSavable;
|
||||
import org.schabi.newpipe.util.debounce.DebounceSaver;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.OnClickGesture;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import icepick.State;
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistLocalItem>, Void> {
|
||||
public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistLocalItem>, Void>
|
||||
implements DebounceSavable {
|
||||
|
||||
private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12;
|
||||
@State
|
||||
protected Parcelable itemsListState;
|
||||
Parcelable itemsListState;
|
||||
|
||||
private Subscription databaseSubscription;
|
||||
private CompositeDisposable disposables = new CompositeDisposable();
|
||||
private LocalPlaylistManager localPlaylistManager;
|
||||
private RemotePlaylistManager remotePlaylistManager;
|
||||
private ItemTouchHelper itemTouchHelper;
|
||||
|
||||
/* Have the bookmarked playlists been fully loaded from db */
|
||||
private AtomicBoolean isLoadingComplete;
|
||||
|
||||
/* Gives enough time to avoid interrupting user sorting operations */
|
||||
@Nullable
|
||||
private DebounceSaver debounceSaver;
|
||||
|
||||
private List<Pair<Long, LocalItem.LocalItemType>> deletedItems;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Fragment LifeCycle - Creation
|
||||
@@ -65,6 +86,11 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
localPlaylistManager = new LocalPlaylistManager(database);
|
||||
remotePlaylistManager = new RemotePlaylistManager(database);
|
||||
disposables = new CompositeDisposable();
|
||||
|
||||
isLoadingComplete = new AtomicBoolean();
|
||||
debounceSaver = new DebounceSaver(3000, this);
|
||||
|
||||
deletedItems = new ArrayList<>();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@@ -94,12 +120,17 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
@Override
|
||||
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
||||
super.initViews(rootView, savedInstanceState);
|
||||
|
||||
itemListAdapter.setUseItemHandle(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initListeners() {
|
||||
super.initListeners();
|
||||
|
||||
itemTouchHelper = new ItemTouchHelper(getItemTouchCallback());
|
||||
itemTouchHelper.attachToRecyclerView(itemsList);
|
||||
|
||||
itemListAdapter.setSelectedListener(new OnClickGesture<>() {
|
||||
@Override
|
||||
public void selected(final LocalItem selectedItem) {
|
||||
@@ -107,7 +138,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
|
||||
if (selectedItem instanceof PlaylistMetadataEntry) {
|
||||
final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem);
|
||||
NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.uid,
|
||||
NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.getUid(),
|
||||
entry.name);
|
||||
|
||||
} else if (selectedItem instanceof PlaylistRemoteEntity) {
|
||||
@@ -128,6 +159,14 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
showRemoteDeleteDialog((PlaylistRemoteEntity) selectedItem);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void drag(final LocalItem selectedItem,
|
||||
final RecyclerView.ViewHolder viewHolder) {
|
||||
if (itemTouchHelper != null) {
|
||||
itemTouchHelper.startDrag(viewHolder);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,8 +178,13 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
public void startLoading(final boolean forceLoad) {
|
||||
super.startLoading(forceLoad);
|
||||
|
||||
Flowable.combineLatest(localPlaylistManager.getPlaylists(),
|
||||
remotePlaylistManager.getPlaylists(), PlaylistLocalItem::merge)
|
||||
if (debounceSaver != null) {
|
||||
disposables.add(debounceSaver.getDebouncedSaver());
|
||||
debounceSaver.setNoChangesToSave();
|
||||
}
|
||||
isLoadingComplete.set(false);
|
||||
|
||||
getMergedOrderedPlaylists(localPlaylistManager, remotePlaylistManager)
|
||||
.onBackpressureLatest()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(getPlaylistsSubscriber());
|
||||
@@ -154,6 +198,9 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
itemsListState = itemsList.getLayoutManager().onSaveInstanceState();
|
||||
|
||||
// Save on exit
|
||||
saveImmediate();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -168,19 +215,27 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
}
|
||||
|
||||
databaseSubscription = null;
|
||||
itemTouchHelper = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
if (debounceSaver != null) {
|
||||
debounceSaver.getDebouncedSaveSignal().onComplete();
|
||||
}
|
||||
if (disposables != null) {
|
||||
disposables.dispose();
|
||||
}
|
||||
|
||||
debounceSaver = null;
|
||||
disposables = null;
|
||||
localPlaylistManager = null;
|
||||
remotePlaylistManager = null;
|
||||
itemsListState = null;
|
||||
|
||||
isLoadingComplete = null;
|
||||
deletedItems = null;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
@@ -188,10 +243,12 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private Subscriber<List<PlaylistLocalItem>> getPlaylistsSubscriber() {
|
||||
return new Subscriber<List<PlaylistLocalItem>>() {
|
||||
return new Subscriber<>() {
|
||||
@Override
|
||||
public void onSubscribe(final Subscription s) {
|
||||
showLoading();
|
||||
isLoadingComplete.set(false);
|
||||
|
||||
if (databaseSubscription != null) {
|
||||
databaseSubscription.cancel();
|
||||
}
|
||||
@@ -201,7 +258,10 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
|
||||
@Override
|
||||
public void onNext(final List<PlaylistLocalItem> subscriptions) {
|
||||
handleResult(subscriptions);
|
||||
if (debounceSaver == null || !debounceSaver.getIsModified()) {
|
||||
handleResult(subscriptions);
|
||||
isLoadingComplete.set(true);
|
||||
}
|
||||
if (databaseSubscription != null) {
|
||||
databaseSubscription.request(1);
|
||||
}
|
||||
@@ -214,7 +274,8 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() { }
|
||||
public void onComplete() {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -249,12 +310,183 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
}
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Playlist Metadata Manipulation
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private void changeLocalPlaylistName(final long id, final String name) {
|
||||
if (localPlaylistManager == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Updating playlist id=[" + id + "] "
|
||||
+ "with new name=[" + name + "] items");
|
||||
}
|
||||
|
||||
final Disposable disposable = localPlaylistManager.renamePlaylist(id, name)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(longs -> { /*Do nothing on success*/ }, throwable -> showError(
|
||||
new ErrorInfo(throwable,
|
||||
UserAction.REQUESTED_BOOKMARK,
|
||||
"Changing playlist name")));
|
||||
disposables.add(disposable);
|
||||
}
|
||||
|
||||
private void deleteItem(final PlaylistLocalItem item) {
|
||||
if (itemListAdapter == null) {
|
||||
return;
|
||||
}
|
||||
itemListAdapter.removeItem(item);
|
||||
|
||||
if (item instanceof PlaylistMetadataEntry) {
|
||||
deletedItems.add(new Pair<>(item.getUid(),
|
||||
LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM));
|
||||
} else if (item instanceof PlaylistRemoteEntity) {
|
||||
deletedItems.add(new Pair<>(item.getUid(),
|
||||
LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM));
|
||||
}
|
||||
|
||||
if (debounceSaver != null) {
|
||||
debounceSaver.setHasChangesToSave();
|
||||
saveImmediate();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveImmediate() {
|
||||
if (itemListAdapter == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// List must be loaded and modified in order to save
|
||||
if (isLoadingComplete == null || debounceSaver == null
|
||||
|| !isLoadingComplete.get() || !debounceSaver.getIsModified()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final List<LocalItem> items = itemListAdapter.getItemsList();
|
||||
final List<PlaylistMetadataEntry> localItemsUpdate = new ArrayList<>();
|
||||
final List<Long> localItemsDeleteUid = new ArrayList<>();
|
||||
final List<PlaylistRemoteEntity> remoteItemsUpdate = new ArrayList<>();
|
||||
final List<Long> remoteItemsDeleteUid = new ArrayList<>();
|
||||
|
||||
// Calculate display index
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
final LocalItem item = items.get(i);
|
||||
|
||||
if (item instanceof PlaylistMetadataEntry
|
||||
&& ((PlaylistMetadataEntry) item).getDisplayIndex() != i) {
|
||||
((PlaylistMetadataEntry) item).setDisplayIndex(i);
|
||||
localItemsUpdate.add((PlaylistMetadataEntry) item);
|
||||
} else if (item instanceof PlaylistRemoteEntity
|
||||
&& ((PlaylistRemoteEntity) item).getDisplayIndex() != i) {
|
||||
((PlaylistRemoteEntity) item).setDisplayIndex(i);
|
||||
remoteItemsUpdate.add((PlaylistRemoteEntity) item);
|
||||
}
|
||||
}
|
||||
|
||||
// Find deleted items
|
||||
for (final Pair<Long, LocalItem.LocalItemType> item : deletedItems) {
|
||||
if (item.second.equals(LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM)) {
|
||||
localItemsDeleteUid.add(item.first);
|
||||
} else if (item.second.equals(LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM)) {
|
||||
remoteItemsDeleteUid.add(item.first);
|
||||
}
|
||||
}
|
||||
|
||||
deletedItems.clear();
|
||||
|
||||
// 1. Update local playlists
|
||||
// 2. Update remote playlists
|
||||
// 3. Set NoChangesToSave
|
||||
disposables.add(localPlaylistManager.updatePlaylists(localItemsUpdate, localItemsDeleteUid)
|
||||
.mergeWith(remotePlaylistManager.updatePlaylists(
|
||||
remoteItemsUpdate, remoteItemsDeleteUid))
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(() -> {
|
||||
if (debounceSaver != null) {
|
||||
debounceSaver.setNoChangesToSave();
|
||||
}
|
||||
},
|
||||
throwable -> showError(new ErrorInfo(throwable,
|
||||
UserAction.REQUESTED_BOOKMARK, "Saving playlist"))
|
||||
));
|
||||
|
||||
}
|
||||
|
||||
private ItemTouchHelper.SimpleCallback getItemTouchCallback() {
|
||||
// if adding grid layout, also include ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT
|
||||
// with an `if (shouldUseGridLayout()) ...`
|
||||
return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN,
|
||||
ItemTouchHelper.ACTION_STATE_IDLE) {
|
||||
@Override
|
||||
public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView,
|
||||
final int viewSize,
|
||||
final int viewSizeOutOfBounds,
|
||||
final int totalSize,
|
||||
final long msSinceStartScroll) {
|
||||
final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView,
|
||||
viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll);
|
||||
final int minimumAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY,
|
||||
Math.abs(standardSpeed));
|
||||
return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onMove(@NonNull final RecyclerView recyclerView,
|
||||
@NonNull final RecyclerView.ViewHolder source,
|
||||
@NonNull final RecyclerView.ViewHolder target) {
|
||||
|
||||
// Allow swap LocalBookmarkPlaylistItemHolder and RemoteBookmarkPlaylistItemHolder.
|
||||
if (itemListAdapter == null
|
||||
|| source.getItemViewType() != target.getItemViewType()
|
||||
&& !(
|
||||
(
|
||||
(source instanceof LocalBookmarkPlaylistItemHolder)
|
||||
|| (source instanceof RemoteBookmarkPlaylistItemHolder)
|
||||
)
|
||||
&& (
|
||||
(target instanceof LocalBookmarkPlaylistItemHolder)
|
||||
|| (target instanceof RemoteBookmarkPlaylistItemHolder)
|
||||
))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final int sourceIndex = source.getBindingAdapterPosition();
|
||||
final int targetIndex = target.getBindingAdapterPosition();
|
||||
final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex);
|
||||
if (isSwapped && debounceSaver != null) {
|
||||
debounceSaver.setHasChangesToSave();
|
||||
}
|
||||
return isSwapped;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isLongPressDragEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isItemViewSwipeEnabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder,
|
||||
final int swipeDir) {
|
||||
// Do nothing.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Utils
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) {
|
||||
showDeleteDialog(item.getName(), remotePlaylistManager.deletePlaylist(item.getUid()));
|
||||
showDeleteDialog(item.getName(), item);
|
||||
}
|
||||
|
||||
private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
|
||||
@@ -262,7 +494,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
final String delete = getString(R.string.delete);
|
||||
final String unsetThumbnail = getString(R.string.unset_playlist_thumbnail);
|
||||
final boolean isThumbnailPermanent = localPlaylistManager
|
||||
.getIsPlaylistThumbnailPermanent(selectedItem.uid);
|
||||
.getIsPlaylistThumbnailPermanent(selectedItem.getUid());
|
||||
|
||||
final ArrayList<String> items = new ArrayList<>();
|
||||
items.add(rename);
|
||||
@@ -275,13 +507,12 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
if (items.get(index).equals(rename)) {
|
||||
showRenameDialog(selectedItem);
|
||||
} else if (items.get(index).equals(delete)) {
|
||||
showDeleteDialog(selectedItem.name,
|
||||
localPlaylistManager.deletePlaylist(selectedItem.uid));
|
||||
showDeleteDialog(selectedItem.name, selectedItem);
|
||||
} else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) {
|
||||
final long thumbnailStreamId = localPlaylistManager
|
||||
.getAutomaticPlaylistThumbnailStreamId(selectedItem.uid);
|
||||
.getAutomaticPlaylistThumbnailStreamId(selectedItem.getUid());
|
||||
localPlaylistManager
|
||||
.changePlaylistThumbnail(selectedItem.uid, thumbnailStreamId, false)
|
||||
.changePlaylistThumbnail(selectedItem.getUid(), thumbnailStreamId, false)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe();
|
||||
}
|
||||
@@ -303,13 +534,13 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
.setView(dialogBinding.getRoot())
|
||||
.setPositiveButton(R.string.rename_playlist, (dialog, which) ->
|
||||
changeLocalPlaylistName(
|
||||
selectedItem.uid,
|
||||
selectedItem.getUid(),
|
||||
dialogBinding.dialogEditText.getText().toString()))
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void showDeleteDialog(final String name, final Single<Integer> deleteReactor) {
|
||||
private void showDeleteDialog(final String name, final PlaylistLocalItem item) {
|
||||
if (activity == null || disposables == null) {
|
||||
return;
|
||||
}
|
||||
@@ -318,35 +549,8 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
||||
.setTitle(name)
|
||||
.setMessage(R.string.delete_playlist_prompt)
|
||||
.setCancelable(true)
|
||||
.setPositiveButton(R.string.delete, (dialog, i) ->
|
||||
disposables.add(deleteReactor
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(ignored -> { /*Do nothing on success*/ }, throwable ->
|
||||
showError(new ErrorInfo(throwable,
|
||||
UserAction.REQUESTED_BOOKMARK,
|
||||
"Deleting playlist")))))
|
||||
.setPositiveButton(R.string.delete, (dialog, i) -> deleteItem(item))
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void changeLocalPlaylistName(final long id, final String name) {
|
||||
if (localPlaylistManager == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Updating playlist id=[" + id + "] "
|
||||
+ "with new name=[" + name + "] items");
|
||||
}
|
||||
|
||||
localPlaylistManager.renamePlaylist(id, name);
|
||||
final Disposable disposable = localPlaylistManager.renamePlaylist(id, name)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(longs -> { /*Do nothing on success*/ }, throwable -> showError(
|
||||
new ErrorInfo(throwable,
|
||||
UserAction.REQUESTED_BOOKMARK,
|
||||
"Changing playlist name")));
|
||||
disposables.add(disposable);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package org.schabi.newpipe.local.bookmark;
|
||||
|
||||
import org.schabi.newpipe.database.playlist.PlaylistLocalItem;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
|
||||
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
|
||||
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
|
||||
/**
|
||||
* Takes care of remote and local playlists at once, hence "merged".
|
||||
*/
|
||||
public final class MergedPlaylistManager {
|
||||
|
||||
private MergedPlaylistManager() {
|
||||
}
|
||||
|
||||
public static Flowable<List<PlaylistLocalItem>> getMergedOrderedPlaylists(
|
||||
final LocalPlaylistManager localPlaylistManager,
|
||||
final RemotePlaylistManager remotePlaylistManager) {
|
||||
return Flowable.combineLatest(
|
||||
localPlaylistManager.getPlaylists(),
|
||||
remotePlaylistManager.getPlaylists(),
|
||||
MergedPlaylistManager::merge
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge localPlaylists and remotePlaylists by the display index.
|
||||
* If two items have the same display index, sort them in {@code CASE_INSENSITIVE_ORDER}.
|
||||
*
|
||||
* @param localPlaylists local playlists, already sorted by display index
|
||||
* @param remotePlaylists remote playlists, already sorted by display index
|
||||
* @return merged playlists
|
||||
*/
|
||||
public static List<PlaylistLocalItem> merge(
|
||||
final List<PlaylistMetadataEntry> localPlaylists,
|
||||
final List<PlaylistRemoteEntity> remotePlaylists) {
|
||||
|
||||
// This algorithm is similar to the merge operation in merge sort.
|
||||
final List<PlaylistLocalItem> result = new ArrayList<>(
|
||||
localPlaylists.size() + remotePlaylists.size());
|
||||
final List<PlaylistLocalItem> itemsWithSameIndex = new ArrayList<>();
|
||||
|
||||
int i = 0;
|
||||
int j = 0;
|
||||
while (i < localPlaylists.size()) {
|
||||
while (j < remotePlaylists.size()) {
|
||||
if (remotePlaylists.get(j).getDisplayIndex()
|
||||
<= localPlaylists.get(i).getDisplayIndex()) {
|
||||
addItem(result, remotePlaylists.get(j), itemsWithSameIndex);
|
||||
j++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
addItem(result, localPlaylists.get(i), itemsWithSameIndex);
|
||||
i++;
|
||||
}
|
||||
while (j < remotePlaylists.size()) {
|
||||
addItem(result, remotePlaylists.get(j), itemsWithSameIndex);
|
||||
j++;
|
||||
}
|
||||
addItemsWithSameIndex(result, itemsWithSameIndex);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void addItem(final List<PlaylistLocalItem> result,
|
||||
final PlaylistLocalItem item,
|
||||
final List<PlaylistLocalItem> itemsWithSameIndex) {
|
||||
if (!itemsWithSameIndex.isEmpty()
|
||||
&& itemsWithSameIndex.get(0).getDisplayIndex() != item.getDisplayIndex()) {
|
||||
// The new item has a different display index, add previous items with same
|
||||
// index to the result.
|
||||
addItemsWithSameIndex(result, itemsWithSameIndex);
|
||||
itemsWithSameIndex.clear();
|
||||
}
|
||||
itemsWithSameIndex.add(item);
|
||||
}
|
||||
|
||||
private static void addItemsWithSameIndex(final List<PlaylistLocalItem> result,
|
||||
final List<PlaylistLocalItem> itemsWithSameIndex) {
|
||||
Collections.sort(itemsWithSameIndex,
|
||||
Comparator.comparing(PlaylistLocalItem::getOrderingName,
|
||||
Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)));
|
||||
result.addAll(itemsWithSameIndex);
|
||||
}
|
||||
}
|
||||
@@ -155,14 +155,14 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
|
||||
|
||||
final Toast successToast = Toast.makeText(getContext(), toastText, Toast.LENGTH_SHORT);
|
||||
|
||||
playlistDisposables.add(manager.appendToPlaylist(playlist.uid, streams)
|
||||
playlistDisposables.add(manager.appendToPlaylist(playlist.getUid(), streams)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(ignored -> {
|
||||
successToast.show();
|
||||
|
||||
if (playlist.thumbnailUrl.equals(PlaylistEntity.DEFAULT_THUMBNAIL)) {
|
||||
playlistDisposables.add(manager
|
||||
.changePlaylistThumbnail(playlist.uid, streams.get(0).getUid(),
|
||||
.changePlaylistThumbnail(playlist.getUid(), streams.get(0).getUid(),
|
||||
false)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(ignore -> successToast.show()));
|
||||
|
||||
@@ -38,7 +38,6 @@ import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.math.MathUtils
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
@@ -60,6 +59,7 @@ import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import org.schabi.newpipe.databinding.FragmentFeedBinding
|
||||
import org.schabi.newpipe.error.ErrorInfo
|
||||
import org.schabi.newpipe.error.ErrorUtil
|
||||
import org.schabi.newpipe.error.UserAction
|
||||
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
@@ -453,24 +453,33 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
if (t is FeedLoadService.RequestException &&
|
||||
t.cause is ContentNotAvailableException
|
||||
) {
|
||||
Single.fromCallable {
|
||||
NewPipeDatabase.getInstance(requireContext()).subscriptionDAO()
|
||||
.getSubscription(t.subscriptionId)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ subscriptionEntity ->
|
||||
handleFeedNotAvailable(
|
||||
subscriptionEntity,
|
||||
t.cause,
|
||||
errors.subList(i + 1, errors.size)
|
||||
)
|
||||
},
|
||||
{ throwable -> Log.e(TAG, "Unable to process", throwable) }
|
||||
)
|
||||
return // this will be called on the remaining errors by handleFeedNotAvailable()
|
||||
disposables.add(
|
||||
Single.fromCallable {
|
||||
NewPipeDatabase.getInstance(requireContext()).subscriptionDAO()
|
||||
.getSubscription(t.subscriptionId)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ subscriptionEntity ->
|
||||
handleFeedNotAvailable(
|
||||
subscriptionEntity,
|
||||
t.cause,
|
||||
errors.subList(i + 1, errors.size)
|
||||
)
|
||||
},
|
||||
{ throwable -> Log.e(TAG, "Unable to process", throwable) }
|
||||
)
|
||||
)
|
||||
// this will be called on the remaining errors by handleFeedNotAvailable()
|
||||
return@handleItemsErrors
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.isNotEmpty()) {
|
||||
// if no error was a ContentNotAvailableException, show a general error snackbar
|
||||
ErrorUtil.showSnackbar(this, ErrorInfo(errors, UserAction.REQUESTED_FEED, ""))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFeedNotAvailable(
|
||||
@@ -579,7 +588,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
// state until the user scrolls them out of the visible area which causes a update/bind-call
|
||||
groupAdapter.notifyItemRangeChanged(
|
||||
0,
|
||||
MathUtils.clamp(highlightCount, lastNewItemsCount, groupAdapter.itemCount)
|
||||
highlightCount.coerceIn(lastNewItemsCount, groupAdapter.itemCount)
|
||||
)
|
||||
|
||||
if (highlightCount > 0) {
|
||||
@@ -598,9 +607,13 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
execOnEnd = {
|
||||
// Disabled animations would result in immediately hiding the button
|
||||
// after it showed up
|
||||
if (DeviceUtils.hasAnimationsAnimatorDurationEnabled(context)) {
|
||||
// Hide the new items-"popup" after 10s
|
||||
hideNewItemsLoaded(true, 10000)
|
||||
// Context can be null in some cases, so we have to make sure it is not null in
|
||||
// order to avoid a NullPointerException
|
||||
context?.let {
|
||||
if (DeviceUtils.hasAnimationsAnimatorDurationEnabled(it)) {
|
||||
// Hide the new items button after 10s
|
||||
hideNewItemsLoaded(true, 10000)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -13,9 +13,9 @@ sealed class FeedState {
|
||||
|
||||
data class LoadedState(
|
||||
val items: List<StreamItem>,
|
||||
val oldestUpdate: OffsetDateTime? = null,
|
||||
val oldestUpdate: OffsetDateTime?,
|
||||
val notLoadedCount: Long,
|
||||
val itemsErrors: List<Throwable> = emptyList()
|
||||
val itemsErrors: List<Throwable>
|
||||
) : FeedState()
|
||||
|
||||
data class ErrorState(
|
||||
|
||||
@@ -86,7 +86,7 @@ class FeedViewModel(
|
||||
.subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) ->
|
||||
mutableStateLiveData.postValue(
|
||||
when (event) {
|
||||
is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount)
|
||||
is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, listOf())
|
||||
is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage)
|
||||
is SuccessResultEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, event.itemsErrors)
|
||||
is ErrorResultEvent -> FeedState.ErrorState(event.error)
|
||||
|
||||
@@ -18,8 +18,8 @@ import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_AUDIO_STREAM
|
||||
import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_STREAM
|
||||
import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.PicassoHelper
|
||||
import org.schabi.newpipe.util.StreamTypeUtil
|
||||
import org.schabi.newpipe.util.image.PicassoHelper
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.function.Consumer
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.schabi.newpipe.local.feed.notifications
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
@@ -12,46 +14,41 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.squareup.picasso.Picasso
|
||||
import com.squareup.picasso.Target
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.service.FeedUpdateInfo
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.NavigationHelper
|
||||
import org.schabi.newpipe.util.PicassoHelper
|
||||
import org.schabi.newpipe.util.image.PicassoHelper
|
||||
|
||||
/**
|
||||
* Helper for everything related to show notifications about new streams to the user.
|
||||
*/
|
||||
class NotificationHelper(val context: Context) {
|
||||
|
||||
private val manager = context.getSystemService(
|
||||
Context.NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
|
||||
private val manager = NotificationManagerCompat.from(context)
|
||||
private val iconLoadingTargets = ArrayList<Target>()
|
||||
|
||||
/**
|
||||
* Show a notification about new streams from a single channel.
|
||||
* Opening the notification will open the corresponding channel page.
|
||||
* Show notifications for new streams from a single channel. The individual notifications are
|
||||
* expandable on Android 7.0 and later.
|
||||
*
|
||||
* Opening the summary notification will open the corresponding channel page. Opening the
|
||||
* individual notifications will open the corresponding video.
|
||||
*/
|
||||
fun displayNewStreamsNotification(data: FeedUpdateInfo) {
|
||||
val newStreams: List<StreamInfoItem> = data.newStreams
|
||||
fun displayNewStreamsNotifications(data: FeedUpdateInfo) {
|
||||
val newStreams = data.newStreams
|
||||
val summary = context.resources.getQuantityString(
|
||||
R.plurals.new_streams, newStreams.size, newStreams.size
|
||||
)
|
||||
val builder = NotificationCompat.Builder(
|
||||
val summaryBuilder = NotificationCompat.Builder(
|
||||
context,
|
||||
context.getString(R.string.streams_notification_channel_id)
|
||||
)
|
||||
.setContentTitle(Localization.concatenateStrings(data.name, summary))
|
||||
.setContentText(
|
||||
data.listInfo.relatedItems.joinToString(
|
||||
context.getString(R.string.enumeration_comma)
|
||||
) { x -> x.name }
|
||||
)
|
||||
.setContentTitle(data.name)
|
||||
.setContentText(summary)
|
||||
.setNumber(newStreams.size)
|
||||
.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
@@ -60,21 +57,23 @@ class NotificationHelper(val context: Context) {
|
||||
.setColorized(true)
|
||||
.setAutoCancel(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
|
||||
.setGroupSummary(true)
|
||||
.setGroup(data.url)
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
|
||||
|
||||
// Build style
|
||||
// Build a summary notification for Android versions < 7.0
|
||||
val style = NotificationCompat.InboxStyle()
|
||||
.setBigContentTitle(data.name)
|
||||
newStreams.forEach { style.addLine(it.name) }
|
||||
style.setSummaryText(summary)
|
||||
style.setBigContentTitle(data.name)
|
||||
builder.setStyle(style)
|
||||
summaryBuilder.setStyle(style)
|
||||
|
||||
// open the channel page when clicking on the notification
|
||||
builder.setContentIntent(
|
||||
// open the channel page when clicking on the summary notification
|
||||
summaryBuilder.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
context,
|
||||
data.pseudoId,
|
||||
NavigationHelper
|
||||
.getChannelIntent(context, data.listInfo.serviceId, data.listInfo.url)
|
||||
.getChannelIntent(context, data.serviceId, data.url)
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
0,
|
||||
false
|
||||
@@ -84,13 +83,23 @@ class NotificationHelper(val context: Context) {
|
||||
// a Target is like a listener for image loading events
|
||||
val target = object : Target {
|
||||
override fun onBitmapLoaded(bitmap: Bitmap, from: Picasso.LoadedFrom) {
|
||||
builder.setLargeIcon(bitmap) // set only if there is actually one
|
||||
manager.notify(data.pseudoId, builder.build())
|
||||
// set channel icon only if there is actually one (for Android versions < 7.0)
|
||||
summaryBuilder.setLargeIcon(bitmap)
|
||||
|
||||
// Show individual stream notifications, set channel icon only if there is actually
|
||||
// one
|
||||
showStreamNotifications(newStreams, data.serviceId, bitmap)
|
||||
// Show summary notification
|
||||
manager.notify(data.pseudoId, summaryBuilder.build())
|
||||
|
||||
iconLoadingTargets.remove(this) // allow it to be garbage-collected
|
||||
}
|
||||
|
||||
override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) {
|
||||
manager.notify(data.pseudoId, builder.build())
|
||||
// Show individual stream notifications
|
||||
showStreamNotifications(newStreams, data.serviceId, null)
|
||||
// Show summary notification
|
||||
manager.notify(data.pseudoId, summaryBuilder.build())
|
||||
iconLoadingTargets.remove(this) // allow it to be garbage-collected
|
||||
}
|
||||
|
||||
@@ -106,6 +115,49 @@ class NotificationHelper(val context: Context) {
|
||||
PicassoHelper.loadNotificationIcon(data.avatarUrl).into(target)
|
||||
}
|
||||
|
||||
private fun showStreamNotifications(
|
||||
newStreams: List<StreamInfoItem>,
|
||||
serviceId: Int,
|
||||
channelIcon: Bitmap?
|
||||
) {
|
||||
for (stream in newStreams) {
|
||||
val notification = createStreamNotification(stream, serviceId, channelIcon)
|
||||
manager.notify(stream.url.hashCode(), notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createStreamNotification(
|
||||
item: StreamInfoItem,
|
||||
serviceId: Int,
|
||||
channelIcon: Bitmap?
|
||||
): Notification {
|
||||
return NotificationCompat.Builder(
|
||||
context,
|
||||
context.getString(R.string.streams_notification_channel_id)
|
||||
)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setLargeIcon(channelIcon)
|
||||
.setContentTitle(item.name)
|
||||
.setContentText(item.uploaderName)
|
||||
.setGroup(item.uploaderUrl)
|
||||
.setColor(ContextCompat.getColor(context, R.color.ic_launcher_background))
|
||||
.setColorized(true)
|
||||
.setAutoCancel(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
|
||||
.setContentIntent(
|
||||
// Open the stream link in the player when clicking on the notification.
|
||||
PendingIntentCompat.getActivity(
|
||||
context,
|
||||
item.url.hashCode(),
|
||||
NavigationHelper.getStreamIntent(context, serviceId, item.url, item.name),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
false
|
||||
)
|
||||
)
|
||||
.setSilent(true) // Avoid creating noise for individual stream notifications.
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Check whether notifications are enabled on the device.
|
||||
@@ -124,9 +176,7 @@ class NotificationHelper(val context: Context) {
|
||||
fun areNotificationsEnabledOnDevice(context: Context): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channelId = context.getString(R.string.streams_notification_channel_id)
|
||||
val manager = context.getSystemService(
|
||||
Context.NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
val manager = context.getSystemService<NotificationManager>()!!
|
||||
val enabled = manager.areNotificationsEnabled()
|
||||
val channel = manager.getNotificationChannel(channelId)
|
||||
val importance = channel?.importance
|
||||
|
||||
@@ -55,7 +55,7 @@ class NotificationWorker(
|
||||
.map { feedUpdateInfoList ->
|
||||
// display notifications for each feedUpdateInfo (i.e. channel)
|
||||
feedUpdateInfoList.forEach { feedUpdateInfo ->
|
||||
notificationHelper.displayNewStreamsNotification(feedUpdateInfo)
|
||||
notificationHelper.displayNewStreamsNotifications(feedUpdateInfo)
|
||||
}
|
||||
return@map Result.success()
|
||||
}
|
||||
@@ -137,7 +137,7 @@ class NotificationWorker(
|
||||
.enqueueUniquePeriodicWork(
|
||||
WORK_TAG,
|
||||
if (force) {
|
||||
ExistingPeriodicWorkPolicy.REPLACE
|
||||
ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
|
||||
} else {
|
||||
ExistingPeriodicWorkPolicy.KEEP
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@ object FeedEventManager {
|
||||
}
|
||||
|
||||
sealed class Event {
|
||||
object IdleEvent : Event()
|
||||
data object IdleEvent : Event()
|
||||
data class ProgressEvent(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : Event() {
|
||||
constructor(@StringRes progressMessage: Int) : this(-1, -1, progressMessage)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.schabi.newpipe.local.feed.service
|
||||
|
||||
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
|
||||
@@ -13,11 +14,17 @@ import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.extractor.ListInfo
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import org.schabi.newpipe.extractor.Info
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
import org.schabi.newpipe.extractor.feed.FeedInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.FeedDatabaseManager
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
import org.schabi.newpipe.util.ChannelTabHelper
|
||||
import org.schabi.newpipe.util.ExtractorHelper.getChannelInfo
|
||||
import org.schabi.newpipe.util.ExtractorHelper.getChannelTab
|
||||
import org.schabi.newpipe.util.ExtractorHelper.getMoreChannelTabItems
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -75,7 +82,9 @@ class FeedLoadManager(private val context: Context) {
|
||||
* subscriptions which have not been updated within the feed updated threshold
|
||||
*/
|
||||
val outdatedSubscriptions = when (groupId) {
|
||||
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
|
||||
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(
|
||||
outdatedThreshold
|
||||
)
|
||||
GROUP_NOTIFICATION_ENABLED -> feedDatabaseManager.outdatedSubscriptionsWithNotificationMode(
|
||||
outdatedThreshold, NotificationMode.ENABLED
|
||||
)
|
||||
@@ -101,52 +110,7 @@ class FeedLoadManager(private val context: Context) {
|
||||
.runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
|
||||
.filter { !cancelSignal.get() }
|
||||
.map { subscriptionEntity ->
|
||||
var error: Throwable? = null
|
||||
try {
|
||||
// check for and load new streams
|
||||
// either by using the dedicated feed method or by getting the channel info
|
||||
val listInfo = if (useFeedExtractor) {
|
||||
ExtractorHelper
|
||||
.getFeedInfoFallbackToChannelInfo(
|
||||
subscriptionEntity.serviceId,
|
||||
subscriptionEntity.url
|
||||
)
|
||||
.onErrorReturn {
|
||||
error = it // store error, otherwise wrapped into RuntimeException
|
||||
throw it
|
||||
}
|
||||
.blockingGet()
|
||||
} else {
|
||||
ExtractorHelper
|
||||
.getChannelInfo(
|
||||
subscriptionEntity.serviceId,
|
||||
subscriptionEntity.url,
|
||||
true
|
||||
)
|
||||
.onErrorReturn {
|
||||
error = it // store error, otherwise wrapped into RuntimeException
|
||||
throw it
|
||||
}
|
||||
.blockingGet()
|
||||
} as ListInfo<StreamInfoItem>
|
||||
|
||||
return@map Notification.createOnNext(
|
||||
FeedUpdateInfo(
|
||||
subscriptionEntity,
|
||||
listInfo
|
||||
)
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
if (error == null) {
|
||||
// do this to prevent blockingGet() from wrapping into RuntimeException
|
||||
error = e
|
||||
}
|
||||
|
||||
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
|
||||
val wrapper =
|
||||
FeedLoadService.RequestException(subscriptionEntity.uid, request, error!!)
|
||||
return@map Notification.createOnError<FeedUpdateInfo>(wrapper)
|
||||
}
|
||||
loadStreams(subscriptionEntity, useFeedExtractor, defaultSharedPreferences)
|
||||
}
|
||||
.sequential()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
@@ -164,7 +128,112 @@ class FeedLoadManager(private val context: Context) {
|
||||
}
|
||||
|
||||
private fun broadcastProgress() {
|
||||
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(currentProgress.get(), maxProgress.get()))
|
||||
FeedEventManager.postEvent(
|
||||
FeedEventManager.Event.ProgressEvent(
|
||||
currentProgress.get(),
|
||||
maxProgress.get()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadStreams(
|
||||
subscriptionEntity: SubscriptionEntity,
|
||||
useFeedExtractor: Boolean,
|
||||
defaultSharedPreferences: SharedPreferences
|
||||
): Notification<FeedUpdateInfo> {
|
||||
var error: Throwable? = null
|
||||
val storeOriginalErrorAndRethrow = { e: Throwable ->
|
||||
// keep original to prevent blockingGet() from wrapping it into RuntimeException
|
||||
error = e
|
||||
throw e
|
||||
}
|
||||
|
||||
try {
|
||||
// check for and load new streams
|
||||
// either by using the dedicated feed method or by getting the channel info
|
||||
var originalInfo: Info? = null
|
||||
var streams: List<StreamInfoItem>? = null
|
||||
val errors = ArrayList<Throwable>()
|
||||
|
||||
if (useFeedExtractor) {
|
||||
NewPipe.getService(subscriptionEntity.serviceId)
|
||||
.getFeedExtractor(subscriptionEntity.url)
|
||||
?.also { feedExtractor ->
|
||||
// the user wants to use a feed extractor and there is one, use it
|
||||
val feedInfo = FeedInfo.getInfo(feedExtractor)
|
||||
errors.addAll(feedInfo.errors)
|
||||
originalInfo = feedInfo
|
||||
streams = feedInfo.relatedItems
|
||||
}
|
||||
}
|
||||
|
||||
if (originalInfo == null) {
|
||||
// use the normal channel tabs extractor if either the user wants it, or
|
||||
// the current service does not have a dedicated feed extractor
|
||||
|
||||
val channelInfo = getChannelInfo(
|
||||
subscriptionEntity.serviceId,
|
||||
subscriptionEntity.url, true
|
||||
)
|
||||
.onErrorReturn(storeOriginalErrorAndRethrow)
|
||||
.blockingGet()
|
||||
errors.addAll(channelInfo.errors)
|
||||
originalInfo = channelInfo
|
||||
|
||||
streams = channelInfo.tabs
|
||||
.filter { tab ->
|
||||
ChannelTabHelper.fetchFeedChannelTab(
|
||||
context,
|
||||
defaultSharedPreferences,
|
||||
tab
|
||||
)
|
||||
}
|
||||
.map {
|
||||
Pair(
|
||||
getChannelTab(subscriptionEntity.serviceId, it, true)
|
||||
.onErrorReturn(storeOriginalErrorAndRethrow)
|
||||
.blockingGet(),
|
||||
it
|
||||
)
|
||||
}
|
||||
.flatMap { (channelTabInfo, linkHandler) ->
|
||||
errors.addAll(channelTabInfo.errors)
|
||||
if (channelTabInfo.relatedItems.isEmpty() &&
|
||||
channelTabInfo.nextPage != null
|
||||
) {
|
||||
val infoItemsPage = getMoreChannelTabItems(
|
||||
subscriptionEntity.serviceId,
|
||||
linkHandler, channelTabInfo.nextPage
|
||||
)
|
||||
.blockingGet()
|
||||
|
||||
errors.addAll(infoItemsPage.errors)
|
||||
return@flatMap infoItemsPage.items
|
||||
} else {
|
||||
return@flatMap channelTabInfo.relatedItems
|
||||
}
|
||||
}
|
||||
.filterIsInstance<StreamInfoItem>()
|
||||
}
|
||||
|
||||
return Notification.createOnNext(
|
||||
FeedUpdateInfo(
|
||||
subscriptionEntity,
|
||||
originalInfo!!,
|
||||
streams!!,
|
||||
errors,
|
||||
)
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
|
||||
val wrapper = FeedLoadService.RequestException(
|
||||
subscriptionEntity.uid,
|
||||
request,
|
||||
// do this to prevent blockingGet() from wrapping into RuntimeException
|
||||
error ?: e
|
||||
)
|
||||
return Notification.createOnError(wrapper)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,24 +272,24 @@ class FeedLoadManager(private val context: Context) {
|
||||
for (notification in list) {
|
||||
when {
|
||||
notification.isOnNext -> {
|
||||
val subscriptionId = notification.value!!.uid
|
||||
val info = notification.value!!.listInfo
|
||||
val info = notification.value!!
|
||||
|
||||
notification.value!!.newStreams = filterNewStreams(
|
||||
notification.value!!.listInfo.relatedItems
|
||||
)
|
||||
notification.value!!.newStreams = filterNewStreams(info.streams)
|
||||
|
||||
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
|
||||
subscriptionManager.updateFromInfo(subscriptionId, info)
|
||||
feedDatabaseManager.upsertAll(info.uid, info.streams)
|
||||
subscriptionManager.updateFromInfo(info)
|
||||
|
||||
if (info.errors.isNotEmpty()) {
|
||||
feedResultsHolder.addErrors(
|
||||
FeedLoadService.RequestException.wrapList(
|
||||
subscriptionId,
|
||||
info
|
||||
)
|
||||
info.errors.map {
|
||||
FeedLoadService.RequestException(
|
||||
info.uid,
|
||||
"${info.serviceId}:${info.url}",
|
||||
it
|
||||
)
|
||||
}
|
||||
)
|
||||
feedDatabaseManager.markAsOutdated(subscriptionId)
|
||||
feedDatabaseManager.markAsOutdated(info.uid)
|
||||
}
|
||||
}
|
||||
notification.isOnError -> {
|
||||
|
||||
@@ -39,8 +39,6 @@ import org.schabi.newpipe.App
|
||||
import org.schabi.newpipe.MainActivity.DEBUG
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
|
||||
import org.schabi.newpipe.extractor.ListInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
|
||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -95,13 +93,7 @@ class FeedLoadService : Service() {
|
||||
.doOnSubscribe {
|
||||
startForeground(NOTIFICATION_ID, notificationBuilder.build())
|
||||
}
|
||||
.subscribe { _, error ->
|
||||
// There seems to be a bug in the kotlin plugin as it tells you when
|
||||
// building that this can't be null:
|
||||
// "Condition 'error != null' is always 'true'"
|
||||
// However it can indeed be null
|
||||
// The suppression may be removed in further versions
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
.subscribe { _, error: Throwable? -> // explicitly mark error as nullable
|
||||
if (error != null) {
|
||||
Log.e(TAG, "Error while storing result", error)
|
||||
handleError(error)
|
||||
@@ -132,17 +124,7 @@ class FeedLoadService : Service() {
|
||||
// Loading & Handling
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
|
||||
class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) {
|
||||
companion object {
|
||||
fun wrapList(subscriptionId: Long, info: ListInfo<StreamInfoItem>): List<Throwable> {
|
||||
val toReturn = ArrayList<Throwable>(info.errors.size)
|
||||
info.errors.mapTo(toReturn) {
|
||||
RequestException(subscriptionId, info.serviceId.toString() + ":" + info.url, it)
|
||||
}
|
||||
return toReturn
|
||||
}
|
||||
}
|
||||
}
|
||||
class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause)
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
// Notification
|
||||
|
||||
@@ -2,33 +2,58 @@ package org.schabi.newpipe.local.feed.service
|
||||
|
||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||
import org.schabi.newpipe.extractor.ListInfo
|
||||
import org.schabi.newpipe.extractor.Info
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.util.image.ImageStrategy
|
||||
|
||||
/**
|
||||
* Instances of this class might stay around in memory for some time while fetching the feed,
|
||||
* because of [FeedLoadManager.BUFFER_COUNT_BEFORE_INSERT]. Therefore this class should contain
|
||||
* as little data as possible to avoid out of memory errors. In particular, avoid storing whole
|
||||
* [ChannelInfo] objects, as they might contain raw JSON info in ready channel tabs link handlers.
|
||||
*/
|
||||
data class FeedUpdateInfo(
|
||||
val uid: Long,
|
||||
@NotificationMode
|
||||
val notificationMode: Int,
|
||||
val name: String,
|
||||
val avatarUrl: String,
|
||||
val listInfo: ListInfo<StreamInfoItem>,
|
||||
val avatarUrl: String?,
|
||||
val url: String,
|
||||
val serviceId: Int,
|
||||
// description and subscriberCount are null if the constructor info is from the fast feed method
|
||||
val description: String?,
|
||||
val subscriberCount: Long?,
|
||||
val streams: List<StreamInfoItem>,
|
||||
val errors: List<Throwable>,
|
||||
) {
|
||||
constructor(
|
||||
subscription: SubscriptionEntity,
|
||||
listInfo: ListInfo<StreamInfoItem>,
|
||||
info: Info,
|
||||
streams: List<StreamInfoItem>,
|
||||
errors: List<Throwable>,
|
||||
) : this(
|
||||
uid = subscription.uid,
|
||||
notificationMode = subscription.notificationMode,
|
||||
name = subscription.name,
|
||||
avatarUrl = subscription.avatarUrl,
|
||||
listInfo = listInfo,
|
||||
name = info.name,
|
||||
avatarUrl = (info as? ChannelInfo)?.avatars?.let {
|
||||
// if the newly fetched info is not from fast feed, then it contains updated avatars
|
||||
ImageStrategy.imageListToDbUrl(it)
|
||||
} ?: subscription.avatarUrl,
|
||||
url = info.url,
|
||||
serviceId = info.serviceId,
|
||||
// there is no description and subscriberCount in the fast feed
|
||||
description = (info as? ChannelInfo)?.description,
|
||||
subscriberCount = (info as? ChannelInfo)?.subscriberCount,
|
||||
streams = streams,
|
||||
errors = errors,
|
||||
)
|
||||
|
||||
/**
|
||||
* Integer id, can be used as notification id, etc.
|
||||
*/
|
||||
val pseudoId: Int
|
||||
get() = listInfo.url.hashCode()
|
||||
get() = url.hashCode()
|
||||
|
||||
lateinit var newStreams: List<StreamInfoItem>
|
||||
}
|
||||
|
||||
@@ -28,14 +28,16 @@ import org.schabi.newpipe.databinding.StatisticPlaylistControlBinding;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.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.info_list.dialog.StreamDialogDefaultEntry;
|
||||
import org.schabi.newpipe.util.PlayButtonHelper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
@@ -49,7 +51,8 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
public class StatisticsPlaylistFragment
|
||||
extends BaseLocalListFragment<List<StreamStatisticsEntry>, Void> {
|
||||
extends BaseLocalListFragment<List<StreamStatisticsEntry>, Void>
|
||||
implements PlaylistControlViewHolder {
|
||||
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||
@State
|
||||
Parcelable itemsListState;
|
||||
@@ -195,14 +198,9 @@ public class StatisticsPlaylistFragment
|
||||
if (itemListAdapter != null) {
|
||||
itemListAdapter.unsetSelectedListener();
|
||||
}
|
||||
if (playlistControlBinding != null) {
|
||||
playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(null);
|
||||
playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(null);
|
||||
playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(null);
|
||||
|
||||
headerBinding = null;
|
||||
playlistControlBinding = null;
|
||||
}
|
||||
headerBinding = null;
|
||||
playlistControlBinding = null;
|
||||
|
||||
if (databaseSubscription != null) {
|
||||
databaseSubscription.cancel();
|
||||
@@ -276,12 +274,8 @@ public class StatisticsPlaylistFragment
|
||||
itemsListState = null;
|
||||
}
|
||||
|
||||
playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view ->
|
||||
NavigationHelper.playOnMainPlayer(activity, getPlayQueue()));
|
||||
playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view ->
|
||||
NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false));
|
||||
playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view ->
|
||||
NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false));
|
||||
PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this);
|
||||
|
||||
headerBinding.sortButton.setOnClickListener(view -> toggleSortMode());
|
||||
|
||||
hideLoading();
|
||||
@@ -374,7 +368,7 @@ public class StatisticsPlaylistFragment
|
||||
}
|
||||
}
|
||||
|
||||
private PlayQueue getPlayQueue() {
|
||||
public PlayQueue getPlayQueue() {
|
||||
return getPlayQueue(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.schabi.newpipe.local.holder;
|
||||
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.LocalItem;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
|
||||
import org.schabi.newpipe.local.LocalItemBuilder;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
public class LocalBookmarkPlaylistItemHolder extends LocalPlaylistItemHolder {
|
||||
private final View itemHandleView;
|
||||
|
||||
public LocalBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder,
|
||||
final ViewGroup parent) {
|
||||
this(infoItemBuilder, R.layout.list_playlist_bookmark_item, parent);
|
||||
}
|
||||
|
||||
LocalBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId,
|
||||
final ViewGroup parent) {
|
||||
super(infoItemBuilder, layoutId, parent);
|
||||
itemHandleView = itemView.findViewById(R.id.itemHandle);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateFromItem(final LocalItem localItem,
|
||||
final HistoryRecordManager historyRecordManager,
|
||||
final DateTimeFormatter dateTimeFormatter) {
|
||||
if (!(localItem instanceof PlaylistMetadataEntry)) {
|
||||
return;
|
||||
}
|
||||
final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem;
|
||||
|
||||
itemHandleView.setOnTouchListener(getOnTouchListener(item));
|
||||
|
||||
super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter);
|
||||
}
|
||||
|
||||
private View.OnTouchListener getOnTouchListener(final PlaylistMetadataEntry item) {
|
||||
return (view, motionEvent) -> {
|
||||
view.performClick();
|
||||
if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null
|
||||
&& motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) {
|
||||
itemBuilder.getOnItemSelectedListener().drag(item,
|
||||
LocalBookmarkPlaylistItemHolder.this);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
|
||||
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
|
||||
import org.schabi.newpipe.local.LocalItemBuilder;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.util.PicassoHelper;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
@@ -16,7 +16,7 @@ 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.PicassoHelper;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
import org.schabi.newpipe.views.AnimatedProgressBar;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ 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.PicassoHelper;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
import org.schabi.newpipe.views.AnimatedProgressBar;
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.schabi.newpipe.local.holder;
|
||||
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.LocalItem;
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
|
||||
import org.schabi.newpipe.local.LocalItemBuilder;
|
||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
public class RemoteBookmarkPlaylistItemHolder extends RemotePlaylistItemHolder {
|
||||
private final View itemHandleView;
|
||||
|
||||
public RemoteBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder,
|
||||
final ViewGroup parent) {
|
||||
this(infoItemBuilder, R.layout.list_playlist_bookmark_item, parent);
|
||||
}
|
||||
|
||||
RemoteBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId,
|
||||
final ViewGroup parent) {
|
||||
super(infoItemBuilder, layoutId, parent);
|
||||
itemHandleView = itemView.findViewById(R.id.itemHandle);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateFromItem(final LocalItem localItem,
|
||||
final HistoryRecordManager historyRecordManager,
|
||||
final DateTimeFormatter dateTimeFormatter) {
|
||||
if (!(localItem instanceof PlaylistRemoteEntity)) {
|
||||
return;
|
||||
}
|
||||
final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem;
|
||||
|
||||
itemHandleView.setOnTouchListener(getOnTouchListener(item));
|
||||
|
||||
super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter);
|
||||
}
|
||||
|
||||
private View.OnTouchListener getOnTouchListener(final PlaylistRemoteEntity item) {
|
||||
return (view, motionEvent) -> {
|
||||
view.performClick();
|
||||
if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null
|
||||
&& motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) {
|
||||
itemBuilder.getOnItemSelectedListener().drag(item,
|
||||
RemoteBookmarkPlaylistItemHolder.this);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user